终极编程:关于TDD和设计模式的猜想

2021年3月8日16:40:14 发表评论 1,023 次浏览

本文概述

前言

TDD和设计模式是编程的两大美女,非常诱人,学起来不难,但是你可能并不真的会用。为什么?因为不够帅啊,又穷,怎么办?——这就是为什么我们需要好好讨论一下TDD和设计模式了,不够帅,也得有才又不穷啊!

看过不少文章有讨论或吐槽TDD和设计模式的,先是一大段的嫌弃,最后总结一句“虽然没什么用,但是我们还是要学一下的”。问题来了,要是没用,你学他来干嘛呢?这是老实话,要是你觉得没用,大可不学,等到你发觉有用了再学也没关系的,真的没关系,你先按照自己的想法编程。不然学了一堆自己都不知道怎么用的东西,浪费时间和精力,这是浪费生命!

但是本文不是吐槽的,本文是让TDD和设计模式发挥实际作用的一个尝试,但是这里不会重点讨论TDD,假设你已经了解TDD了。

本文的重点是从TDD的角度理解设计模式,这简直是终极武器!不过这是一个大胆的尝试,你可以参考一下。

需求文档和TDD

产品设计最终产出其中最重要的一个东西叫做需求文档,而需求文档里面主要的又是交互原型图,交互原型图包括界面主元素设计、界面元素的详细分点标注。

我们开发的时候主要参考需求文档,更为参考的则是交互原型图。编程的主要方法论是TDD,TDD设计接口的标准来自需求文档,文档是怎么描述的,开发就怎么实现,无需多说,人生已经很累了,就不要改来改去了。

参考交互原型图进行设计的时候,设计的代码需要包含程序的输入、程序的输出,程序的输入包括用户触摸屏幕、或鼠标点击屏幕、输入框的输入等,但凡数据的方向是进入程序的,都属于输入。程序的输出包括界面元素更新、按钮更新、进度条、文本更新、控件样式更新等,但凡数据方向是进入屏幕的,都输入输出,或者又叫做程序/系统响应。

更新当前界面的输入和输出,我们可以很好很快地设计好程序需要的接口。如果这些接口能够最终完成当前的所有需求,那么这就是一个好的设计。一般来说,使用TDD进行的设计,其接口都是很标准的,漂亮而干净。

下面我们重点来谈谈设计模式,如果你还不是很懂TDD,请查看我之前的TDD的相关文章,其中有一些详细说明,包含相关的开发示例。这里使用TDD来理解设计模式是非常重要的,所以如果你不是很懂TDD,那么可能有困难。

代码的具体设计包括什么?

代码设计具体包括:

  • 创建对象。
  • 构造函数的参数。
  • 函数。
  • 函数的参数。

不管多么复杂的程序,我们在编程的时候,除了写简单的数据类型和一些语句,其它情况中,我们总需要创建一个对象,然后调用一个函数完成指定任务。

对于构造对象的设计,我们需要考虑以下问题:

  • 该对象在内存中是长期驻留还是临时驻留,长期驻留的对象需要使用static,临时驻留则使用普通创建。
  • 该对象是单例还是多例,单例的意思是:对于这个类模板,程序全局只能存在一个唯一的对象实例,而多例则是任何时候new该类都产生不同的对象实例。
  • 创建该对象是否需要一些复杂的配置,需要提供复杂配置的对象,一般可以将这些配置信息封装为另一个类,或者类似使用建造者模式那样。
  • 是否需要经常使用该对象的实例,也就是说需要频繁创建、使用并释放该对象,例如数据库连接,解决这个需求通常可以预先缓存一定数量的对象。

以上设计的问题,其实就是设计模式中创建型模式要解决的问题。这是在你想要new一个对象的时候要考虑的问题。

对于构造函数的参数,一般要考虑以下问题:

  • 该对象是否可作为数据对象,意思是这个对象是负责封装数据的(或者该对象特有的数据),例如一些简单的Java对象,其中只有getter和setter函数。一般这种是可以将这些属性作为构造函数的参数。除非有必要,否则,一个主要封装算法的类是不需要过度设计构造函数的参数的(除了将数据结构和算法都集中在一个类中了)。
  • 构造函数的参数是否可以使用一个对象封装,如果一个构造函数参数数量过多,那你可能就要考虑将这些参数都封装到一个对象中了。

设计构造函数是在你考虑完new对象后需要考虑的问题,这就是设计了,而不是不经大脑思考就乱写代码。

TDD设计接口遵循的原则

使用TDD开发时,编写任何代码先编写测试用例,这个用例也不是随便写的,它也是要经过设计。在这种从上而下的设计中,建议遵循的标准只有一个,那就是习惯,类似JDK那样的使用习惯,并同时要考虑到整体上的设计。

另外,设计步骤同样是:

  • 对象构造,单例、多例、对象池等。
  • 构造函数,参数数量,参数封装等。
  • 功能函数,输入数据,输出数据,输入输出数据的封装等。

这一切,就是我们在编写没一句细致的代码的时候考虑的:先new对象,如何new呢?该对象是否需要传递构造参数?该参数是对象特有或对象中全局共享的?函数的输入值和输出值是什么?

TDD和设计模式

首先声明,我并不打算遵守设计模式的固定代码形式,类似网上大多数关于设计模式的代码,我认为都不需要遵守。而我建议的是,遵循习惯性。而这一切的前提,请先记住我们开发是有一个开发需求文档的,无论什么时候都不要忘记需求文档。

设计模式一般可分为创建型模式、结构型和行为型模式,创建型模式是负责创建对象的,结构型和行为型——我会说,它们主要体现在函数的调用上。

这里假设我们要设计一个HTTP请求的API(需求文档),你会怎么设计?使用TDD吧!必须的。首先HTTP有请求和响应两种数据,例如请求Request封装请求的URL、请求类型(GET/POST)、请求体等,响应Respose封装响应的一些元数据、响应体等,而处理响应和请求的,我们需要使用一个专门的算法类来封装——接着就编写测试用例吧。

这里HTTP的设计其实和我们平时用的也没什么区别,我也只是重述一遍,我也是根据大家一般的使用习惯来设计的。

假设,我们设计的最初测试用例如下:

OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(new LoggingInterceptor())
    .build();

Request request = new Request.Builder()
    .url("http://www.xx.com/helloworld.txt")
    .header("User-Agent", "OkHttp Example")
    .build();

Response response = client.newCall(request).execute();
response.body().close();

首先,你可以发现,以上代码在创建对象的时候使用了一个Builder对象,这样中的理由是:client和request中有很多自定义的配置,如果只使用构造函数或setter函数的方式,则显得非常麻烦,使用builder则显得相当轻量。

没错,这就是从OkHttp中复制过来的代码。而以上代码就是建造者模式的测试用例,而建造者模式的主要意思就是:我创建这个对象有点复杂,需要更多的数据配置,为此我想设计一个类来专门构造这个对象。

那类结构呢?建造者一定需要什么类,什么接口?暂停!我们不需要这个东西,不要遵守这种形式。只需要知道:我的测试用例符合了建造者模式的一般形式即可,这是最简单的了,我们仅需要在用例上设计就能达到指定的设计模式了。

所以,我在这里的尝试是:在设计用例的时候,让这个用例符合一般设计模式的形式,即表示使用了指定的设计模式。

接下来就是实现或重构代码了,我们按照这个用例的形式往下重构代码。往下重构的结果有两个:符合了一般设计模式的类结构、或者相差很大,但是这不要紧,尝试设计每一步代码都按照上面的逻辑这样考虑,那么我们的最终目的就已经达到了。

我认为,我以前或者类似的开发者不懂设计模式的主要原因:是没有使用类似TDD从上而下的逻辑进行思考,比如我以前有时候从整体考虑或者——那就是看不懂的感觉,即使是现在,如果我不采用TDD的方式,那么使用设计模式写出来的代码,其实在我看来,就是一堆无用、又花俏、又费力的代码。

设计模式的初步理解和设计

但是要记住,我这里建议使用TDD理解设计模式只是一个参考,如果你不用TDD开发也能理解,那就更好。这里使用TDD理解设计模式主要是帮助我个人和一些不会使用设计模式的程序员学会使用它,下面提出学习设计模式的一些步骤,如果你不记得了可以回头再看看,记得那就更好,这些步骤如下:

  • 选择一个设计模式,先找到它使用端(客户端)的代码,这个使用端的代码就是类似测试用例的代码(TDD的第一步,先写测试用例)。
  • 从使用端的代码总结该设计模式的一般形式,一些模式是可以直接从使用代码就知道的了,代码特征包括接口或类声明、对象创建的形式、创建对象的参数、函数调用的形式。
  • 如果从使用端的代码不能直接看到该设计模式的一般形式,那么应该从当前用例代码再往下寻找特征。
  • 尝试使用TDD开发,编写一种设计模式的测试用例的代码,往下重构。

每个设计模式都会有一个客户端的调用代码,这个等于TDD中的测试用例代码,我们主要是把这两个代码等同起来。这样对于设计模式不仅是为了学而学了,而是有了使用它的需要。

对于使用设计模式用于实际项目的设计,可以参考一下步骤:

  • 查看并分析你的需求文档,得到当前页面包括哪些输出和输入,包括哪些操作。
  • 在设计测试用例的时候,使用类似或等同某一个设计模式的客户端代码形式设计用例。

如此一来,我们解决了第一个问题:使我们设计的接口符合设计模式的形式,但是要注意,这里并没有说一定要如何创建,有几个参数,只需要大部分的代码符合即可,不需要100%,以后还可以优化不是吗?

TDD和设计模式:往下重构代码

往下重构代码时,主要的方法论还是TDD,例如写完测试用例后,下一个是Controller/Presenter的重构,这个类是负责当前页面的主要业务逻辑的。我们在其中编写详细代码的时候,以数据的读写方式进行编写代码,例如当前页面需要展示所有好友的简要信息、以及添加好友信息,我们可以假设外部有一个方便的类了,这个类有获取好友信息、添加好友信息的方便方法——也就是说,以当前的 Controller/Presenter为基点,任何数据读写都是以这个几点为准,那些提供数据的类需要分出去(提供数据的接口一般又称为Model)。

也就是说,我们是在Controller/Presenter中编写主要的业务需求代码,这时使用端就是Controller/Presenter了,当需要获取和写出数据,这时候编写代码的形式又是和编写测试用例的类似。在每一层中,我们都有一个作为基点的环境,以基点为客户端编写测试用例,测试用例的设计形式参考某一设计模式的使用代码。

另外,使用TDD结合设计模式的用例往下重构代码,要想达到设计模式的代码水平,需要参考设计模式的六大原则。例如,根据这个原则,我们很有必要设计抽象类或接口,而不总是使用具体类。

常见设计模式的用例接口

为了能够在使用TDD开发的时候能够灵活使用设计模式,下面我们尝试总结一下每种设计模式的一般使用代码。这些代码你可以记住,或者稍微记一下——但是其实我们在开发的时候(使用JDK或其它API)经常遇到,也就不需要记了。

对于创建型设计模式,它们的用例代码非常简单。当我们在重构并编写具体代码的时候,需要用到一个对象,那就创建它,创建它要考虑:单例还是多例、是否带有复杂的配置、是否需要创建它。创建新型模式的一般用例代码如下:

// 创建型模式
// 1. 工厂模式
Factory f = new Factory();
Shape s = f.createShape();
Color c = f.createColor();
Http h = f.createHttp();

// 2. 抽象工厂模式
AbstractFactory sf = new ShapeFactory();
Shape s = sf.createShape();

AbstractFactory cf = new ColorFactory();
Color red = cf.createColor();

// 3. 单例模式
Color c = Color.getInstance();

// 4. 建造者模式
Dialog dialog = new DialogBuilder()
        .title("Title")
        .subTitle("SubTitle")
        .description("Description")
        .confirm(new ComfirmListener())
        .cancle(new CancleListener())
        .build();
dialog.show();

// 5. 原型模式
CloundPool cp = new CloundPool();  // 原型对象池
Clound c1 = cp.borrow();
Clound c2 = cp.borrow();
Clound c3 = cp.borrow();
(do something)
cp.return(c1);
cp.return(c2);
cp.return(c3);

其中你可以看到前三种都是类似工厂的模式,相信一般程序员多多少少都用过了。比较新颖的是建造者模式和原型模式,对建造者模式我们在开发中也遇到不少,例如上面提到的OkHttp就是。原型模式是解决对象重复创建的问题,假设我这个项目需要不断地、大量地使用这个对象,那么最好就是设计一个对象池,预先缓存一些对象,原型设计就是这个意思。

一些常见的结构型模式的代码如下:

// 结构型模式
// 1. 适配器模式
AudioPlayerAdapter player = new AudioPlayerAdapter();
player.play(MP3, "mp3.mp3");
player.play(MP4, "mp4.mp4");
player.play(AVI, "avi.mp4");

// 2. 桥接模式
Element e = new Element();
Style style = new Style();
e.setSytle(style);
e.paint();

// 3. 过滤器模式
Filter pf = new PasswordFilter();
Filter nf = new NameFilter();
Filter ff = new FormatFilter();
List<Content> contents = new ArrayList<>();
(contents.add(c)) ...
contents = pf.filter(contents);
contents = nf.filter(contents);
contents = ff.filter(contents);

// 4. 代理设计模式
Bitmap b = new BitmapProxy("s.jpg");
b.display();

有些模式其特征是不够明显的,或者有些模式其形式有好几种,那么最好可以查看一下该模式的具体实现。

对于行为型设计模式就不一一列举了,主要就是找到它的一种或几种形式的用例代码,我们设计的时候主要是参考这些用例代码。为此有几个优化或提升的建议不妨参考一下:

  • 设计代码的时候,请细致地根据对象声明、创建对象、构造函数、参数、函数,详细考虑设计一种标准的代码(可以参考设计模式六大原则),如果你习惯这么做的时候,可能已经不需要记住设计模式了,因为这么做就是在设计。
  • 将21种设计模式的大部分用例代码总结出来,按照设计模式标题、用例形式或种类、用例代码、可选代码结构的形式使用表格记录起来,当你在进行设计的时候可以快速查看这个表格,找到你需要的模式进行使用。

总结

本文是尝试用使用TDD的方式理解并使用设计模式,一方面结合了TDD的设计模式能够解决项目实际需求,另一方面又能设计出可扩展性高、高内聚、低耦合的代码(需要参考设计模式的六大原则)。

到目前看来,这个尝试还是可行的:进行用例设计,查找设计模式用例文档、找到目标设计模式、模仿该设计模式的使用(有必要则模仿该模式的类结构,但是我觉得有时没有很大必要)。

但是具体这个猜想是否可行,最近准备开发新项目,打算使用这个形式进行开发,看看它的可行性以及问题有什么,如果有重大问题,那也会再写一篇文章详细讨论,我发现使用自己的想法写文章可以把一些潜在的问题描述清楚,并且容易得到解决,比起不动手不动笔好多了。

木子山

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: