铜剑技校

仝键的个人技术博客

0%

集合值得单独抽取对象

大部分的现代语言,都提供了集合的类型,list、vector、set、tree、map等,并围绕这些集合的类型,有一整套方便的api工具库,这导致大家不爱对集合进行抽象。

比如一个用户( User )有多个订单( Order )之类。那么实现的时候我们会怎么样呢?往往是一个User有一个叫orders的属性,类型是个List,当然不同语言有不同的列表类型,比如C++可能用的就是vector。代码类似:

1
2
3
class User {
private List<Order> orders;
}

其实仔细想想,我们给Order要定个Order类,不会用Map或者Json这种类型,为啥集合就用List而不是单独抽象一个Orders呢?比如类似这样:

1
2
3
4
class User {
private Orders order;
}
//当然,User也可以有自己的集合对象Users。

实际上,我们在领域概念当中,某个类型的集合其实是一种隐藏概念,其实经常会出现在业务人员或者领域专家的口中,很多需求都是围绕着这种集合概念展开的,我们需要把这种隐藏的概念显示的表达出来以达成模型与代码的一致。否则以List来承载集合概念这样的实现模式,对于简单系统还是能胜任的,但是对于复杂系统,通常是不能胜任的。这么做具体有什么好处呢?我们后面展开聊聊。

注:为了表达方便,我下面将像Order这种表达单个个体的类称之为单体类;将Orders这种表达个体集合的类,称之为集合类。

集合对象的好处

承载集合逻辑

首先它可以承载围绕着集合进行的扩展需求,不至于把这部分需求散到系统的各个地方。举个例子,假设我们有一个需求场景,里面有两种指令,一种是上层指令,我们就叫它Command,一种是下层指令,我们就叫它Action,一个上层指令需要被转换为多个下层指令来执行,也就是一个Command需要根据条件转换为多个Action,这个转换逻辑非常复杂,受各种自身状态和外部环境影响,所以有很多的分支。

那么在这种场景下,我们转换出的Action,到底是一个的List,比如如下代码:

1
2
3
4
5
6
7
8
9
10
11
class Action {
public void exec(ExecContext context){
//...
}
}
//标准库承载集合
class Command_1 {
public List<Action> transform(TransformContext context){
//...
}
}

还是抽象成一个集合对象Actions呢? 比如如下代码:

1
2
3
4
5
6
7
8
9
10
11
//集合对象承载集合
class Command_2 {
public Actions transform(TransformContext context){
//...
}
}

class Actions {
private List<Action> actions;

}

那么我们可以分析一下,如果集合对象Actions不被建模,当出现了对整组产生影响的新需求,比如对整组的action进行统筹优化,或者一组action执行完要发个通知这种新需求。这个新需求的代码放在哪实现呢?要么放在持有List的地方,可能就是执行的地方:

1
2
3
4
5
6
7
//标准库承载集合情况下,执行代码:
exec(Command_1 command){
List<Action> actions = command.transform(transformContext);
for(Action action : actions){
action.exec(execContext);//在这里扩展
}
}

如果放在持有List的地方,那么那里的职责就不单一了,本来只是一个简单调度的职责,现在还要考虑每一组Action的特定逻辑。

再要么入侵到每个Action中去:

1
2
3
4
5
6
class Action {
public void exec(ExecContext context){
//...
//在这里扩展
}
}

如果入侵到每个Action中去,那么Action就会变得越来越臃肿,这也会让他变得越来越不稳定,理论上应该是越下层的越稳定,越上层的越频繁修改,这么一搞,作为下层的Action变成了最不稳定的,反而上层或者中层更稳定了,这个是不合理的。

那么抽象了集合对象,就可以直接在Actions里面扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//集合对象承载集合情况下,执行代码:
exec(Command_1 command){
Actions actions = command.transform(transformContext);
actions.exec(execContext);
}


class Actions {
private List<Action> actions;

void exec(ExecContext execContext){
for(Action action : actions){
action.exec(execContext);//在这里扩展
}
}
}

我们可以看到执行部分的代码因为逻辑没有变化,所以不需要改动代码,Action自身的逻辑没有什么变化,也不需要改动代码。而集合相关的逻辑因为需求发生了变化,在集合相关的类里进行扩展,这是非常合理的事情。我们常说,要建立统一语言,让业务人员与技术人员的认知模型保持一致,从而提高协同效率,那么在提取集合对象这个点上,如果我们做了这件事,那么一定程度上,我们就提升了它们的一致性。

屏蔽物理边界

其次,它可以屏蔽掉内存与物理存储的边界。当单体类之间有一对多关系的时候,如果我们使用语言自带的集合类库来定义类型,如之前的代码里:

1
2
3
class User {
private List<Order> orders;
}

这是非常常见的操作,但是这样的话,这个list里边的内容必须都存在于内存当中。实际中一个用户有多少订单呢?被经年累月使用的电商系统,哪怕是个人的订单,通常订单数量都大的可怕,你总不能都加载进来吧。类似的情况可以说在企业级的应用里非常常见。

因为这个非常非常现实的问题,很多orm提供了一些延迟加载的机制,你用的还是List,只是数据没有加载,直到List真的被访问的时候再加载数据,但是吧,延迟加载最终要么是全加载,要么是一条条加载,后者还因为性能有缺陷产生了专门的问题名词:n+1问题。如果我只关心部分呢?因为现实中数据大都是分页的,或者根据某个条件重新排序、过滤、分组然后再分页,这都很常见。所以这种情况,我们就只能专门为这个数据类型搞个dao或repository之类,像什么OrderRepository这种。代码就会变成下面这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User {
private String id;
}

class Order {
private String userId;
}

interface OrderRepository {
List<Order> findAllByUserId(String userId);

List<Order> findByUserId(String userId, int size, int offset);

//....
}

但是这么一搞的话,所有的这种领域内的数据类就都有了自己的Repository,凡是数据大一点的,都会变成这个样子,一切都变得很碎片化,面向对象范式的价值被削弱了。

另外,类之间的关系就变得不清楚了,起码不能自动识别。这样在全局层面分析出的实体关系与实际的代码无法自动映射。知识传递过程中就出现了大量的隐性知识,在这里就是这个 String 类型的userId是User这个类的id这件事,这种隐性知识使得想要判断模型与代码是否一致就变得困难了,自然会增大维护成本,。

这种情况下,我们让user持有的不是list,而是orders,这个orders可能只是一个接口,我们可以给它加上各种具体的查询方法。

1
2
3
4
5
6
7
8
9
10
11
class User {
private Orders order;
}

interface Orders {
List<Order> findAll();

List<Order> findAll(int size, int offset);

//....
}

于是不再是user持有order,而是user持有orders, orders 持有order。我们让这种集合对象承载了实体间的关系。这也就是所谓的associate object模式。

不过这个做法在技术实现上会比较的难一点,用MyBatis会比较容易一点,用JPA就会比较麻烦。集合对象的API的设计也有点反直觉,我们后续再写文章详细讲讲这个写法怎么落地。

可以根据状态切分上下文

第三,它可以按照状态进行归类,从而划分上下文。当我们抽象集合类的时候,不会傻乎乎的一个单体类就只抽象一个集合类, 比如order就抽象一个orders类。这样的话其实完全没有必要抽象,因为类本身就代表了全集这个概念,配上list等语言原生的集合类库自然就能搞定一切,绕了一圈又绕回去了。而我们需要做的是要把这个全集进行再切分,具体的手法往往是给集合类加上前缀。比如前面提到的user的orders,它的查询范围一定是这个用户的order,而不是所有的order,所以我们可以抽象一个UserOrders,于是前面的代码就变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class User {
private UserOrders order;
}

interface UserOrders {
List<Order> findAll();

List<Order> findAll(int size, int offset);

//....
}

class UserOrdersImpl implements UserOrders {

private String userId;

//.....

}

这个UserOrders查询order的时候会自然的加上用户id这个过滤条件,就实现了查询范围一定是这个用户的order这个能力,而且用起来也很方便:user.getOrders().findxxx。这只是简单应用,更有价值的的是我们可以用状态来切割漫长的业务逻辑。

比如有一个电商系统,用户需要下订单,订单就是我们的单体类。那么围绕订单往往是有一个很长的业务流程,在这个漫长的业务流程当中,订单有很多的状态。比如订单下达后,付款前,叫做待付款订单,付完款之后变成了待发货订单,发货之后变成了待收货订单。在每一个状态下,订单又有很多的分支业务逻辑。比如待付款订单可能有无数种付款方式,付完款之后相应的也可能有无数种确认方式。更不要说后面的待发货,待收货这些状态。每一种状态必然伴随着一大堆的业务逻辑代码,那么我们如何建模呢?一种方式就是建子类,待付款订单、待发货订单,待收货订单各是order的一个子类。这种方式不是不行,抛掉继承本身一些不太好的副作用问题不谈,这种建模的问题是只考虑了单体逻辑。也就是单个订单的逻辑。在业务当中还有一种情况,就是集合逻辑,尤其是这个集合被其他的单体类持有的时候,这种逻辑就完全没地方放。只能随意的对别人get出来,然后在用的地方自己编写,逻辑就是这么泄露的。这个时候呢,我们可以抽出他的集合类。比如叫PendingOrders。一个用户有很多order的时候,比如简单说有四种:全部order(UserOrders),待付款order(UserUnpaidOrders),待发货order(UserUnsendOrders),待收货order(UserUndeliveredOrder),待评价order(UserUncommentedOrders),各自建一组集合类。然后把围绕着他们的集合概念全都放到这些类里面去。你就会发现很多找不到地方放的逻辑突然有地方放了。

这个情况很常见,不只适用于电商领域,漫长的业务逻辑,与之伴生的状态,多种多样的分支,这很明显就会产生复杂的代码,这代码很容易腐化。

当我们按照状态为集合对象建模以后,对于建立统一语言也很有帮助,如果你经常去跟需求方去聊需求。你会发现需求方嘴中的类型和集合是不分的,他可能跟你说待付款订单,这个时候指的不是订单的子类,而是处于待付款这种状态的订单集合,很多人都会苦于如何在这种情况下跟需求方建立统一语言。如果你的建模工具箱里有封装集合对象这个工具,这一切都会就变得容易处理了。

收尾的话

最后,我们必须告诉大家,在面对复杂逻辑的时候,集合对象的抽取会带来很多好处,当然不是说语言自带的集合库就不能直接使用,抽取领域特定的集合类这种建模方式必须是在处理复杂逻辑、面对大量变化点的时候才会有好处,在简单逻辑的场景下就是自己人为的增加复杂度。如何判断是复杂逻辑的场景,则需要我们深入领域,充分理解领域知识,结合设计者丰富的经验,才能做出正确的判断。

参考资料

[1] 《OO Parterns》/(美)Peter Coad
[2] 《如何落地业务建模》/ 徐昊

导语

最近想明白一个道理,设计模式这种东西,在技术维度已经基本穷举了,不管是23种GoF设计模式里的工厂、策略、命令、状态什么的还是23种之外的空对象模式、全局对象模式,仔细看看,描述这些个模式解决的问题,就是业务领域无关的通用问题。即便我们算上函数式和并发设计模式,也是一样,还是业务领域无关的通用问题。也就是说这些模式解决的问题,大都是技术维度的语言就可以描述的问题。

而后续很多发展并不是在技术维度,是在如何将代码与领域模型写的更一致方向上发展了很多模式。当我去跟人交流的时候,他们经常会说,这属于实现吧。很多人总觉得相对于传统设计模式,这种专注于如何把代码写的跟领域模型一致的模式不够高大上,好吧实现就实现吧,纠正人们的偏见太累了,正好Kent Beck写过一本书叫《实现模式》,在这本书前言上他就写到:

这是一本关于编程的书,更具体一点,是关于“如何编写别人能懂的代码”的书。编写出别人能读懂的代码没有任何神奇之处,这就与任何其他形式的写作一样:了解你的阅读者,在脑子里构想一个清晰的整体结构,让每个细节为故事的整体作出贡献。
……
也可以把实现模式看作思考“关于这段代码,我想要告诉阅读者什么?”的一种方式。程序员大部分的时间都在自己的世界里绞尽脑汁,以至于用别人的视角来看待世界对他们来说是一次重大的转变。他们不仅要考虑“计算机会用这段代码做什么”,还要考虑“如何用这段代码与别人沟通我的想法”。这种视角上的转换有利于你的健康,也很可能有利于你的钱包,因为在软件开发中有大量的开销都被用在理解现有代码上了。

正好,那我们就可以遵循着大师的思路,把如何把代码写的与模型一致的这一系列模式,都叫做实现模式。一个程序员,他一定是先获得领域知识输入,然后开始看代码,在领域知识的框架指导下试图理解代码,所以将代码写的与模型一致,就是为了让程序员更好的读懂代码。

那么设计模式高大上,实现模式就不高大上吗?我看未必。这很可能是个屁股问题,而我的屁股,坐在双手沾泥的程序员一边。我觉得实现模式跟设计模式一样高大上,一样值得花很多精力去写,被很多人关注。

有意义的接口

有意义的接口,这个名字我是取自DDD中柔性设计里的第一个模式INTENTION-REVEALING INTERFACES(揭示意图的接口)。里面提到:

如果开发人员为了使用一个组件而必须要去研究它的实现,那么就失去了封装的价值。

这个点非常重要,但是我在实际工作中,看到的更多是对这个点的无视。很多人理解的可读性,只考虑你能读懂我的代码就可以了,但实际上,我们的软件蕴含的信息几乎是可以无限增加的,所以代码的复杂度自然也是无限提升的。毕竟任何问题都可以加一个中间层解决嘛,但是你有没有想过中间层这种东西加多了那本身就是问题。

很多权威的文献也是这么认为的,在《中国学科发展战略·软件科学与工程》中就提到:

软件是人类制造的最复杂的一类制品。高度灵活性使得软件不仅仅是系统中的信息处理工具,也是管理各类资源融合人机物的“万能集成器”。这就使得整个人工系统的复杂性向软件集中,驾驭复杂性的能力(复杂性的种类程度的提升),体现了软件发展的水平。

因为复杂性的可以无限叠加,一个长期存在的软件,其代码的复杂性最终都会膨胀到难以驾驭。简单粗暴地讲,相对于人脑的算力,软件的复杂度可以将看做是无限的。所以在IEEE的《软件工程知识体系指南》里在讲道软件构造的时候,也把“复杂性最小化”排在了第一位。书中写道:

大多数人在工作记忆中保持复杂结构和信息的能力是很有限的,特别是长时间保持记忆。这导致了软件构造的一个最强烈的驱动因素:将复杂性最小化。降低复杂性的要求几乎存在于软件构造的每个方面,特别是软件构造的验证和测试过程。在软件构造中,通过创建简单、可读的代码,而不是“聪明”的代码,可以达到降低复杂性的目标。

我经常说,可读性的意义,在于不读。 我们应该经常对着自己的代码审视,思考哪些代码别人不需要读也能理解自己代码的意图,让自己的代码有层次起来。其中最简单的方法就是像前面说的,把实现封装起来,在接口上把意图揭示。

书里举了一个例子:

Paint类

这是一个Paint类,其要解决的领域问题是调色。按照书上原话就是,一家油漆商店的程序能够为客户显示出标准调漆的结果。 从业务领域的角度说,它的需求是调色,那这里面哪哪都不合理。

首先,属性采用了缩写。r、y、b这三个应该是red、yellow和blue的意思,这还能猜一猜,那v是什么?但是不管能不能猜出来,用缩写其实都是有问题的。缩写从让别人听懂的角度就没有帮助还有害。很多学习材料里,一方面是历史遗留的习惯造成的,一方面是作者自己偷懒,造成了很多人学到了不好的习惯,然后写代码的时候又出于懒惰的心理,干脆就少些几个字母,这不由让我想起了,齐普夫教授的最省力原则:

说话的人希望使用最少的词汇来表现最多的含义,让自己最省力。 但对于听的人来,则希望说话者用简单易懂的词汇来表达,以帮助自己理解。

大家有这样的矛盾,要考虑到代码的生命周期里,阅读的时间要远远大于编写的时间,所以业界还是更推荐为了阅读省力,而不是为了写省力。

当然这对于很多管理不是很正规的团队,就比较难做到,而对于管理比较正规的团队,有Committer机制的,一个Committer的设置就解决了这个问题,因为毕竟Committer的身份就是读而不是写,你写起来费劲关我什么事,我读起来费劲就不让你合入主干。没有Committer机制的,也可以靠Daily Code Reivew、结对编程、代码集体所有制等手段也可以很好的做到这一点。

其次,这个paint也很有问题,paint是干啥的?你这个领域是解决调色问题的,那在调色领域里paint什么意思?

在书中,作者打开了paint的实现代码和对应的测试代码,得出结论,paint干的是调色的功能,就是把两种颜色调成一种。既然如此,你叫paint干什么,直接叫调色不就好了,所以后来就把paint改成了mixin。

整个重构如下:

Paint类重构

面向过程也需要有意义的接口

当然很多人会说,这是面向对象,如果我们写很多过程式的代码,比如C、js,也要注意这些么?其实这个跟你写什么范式的语言并没有关系,我们可以看看SICP第一章里的一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(define (square-iter guess x)
(if (good-enough? guess x)
guess
(square-iter (improve guess x) x)))

(define (improve guess x)
(average guess (/ x guess)))


(define (square x)
(* x x))

(define (good-enough? guess x)
(< (abs (- (square guess) x)) 0.001))

(define (sqrt x)
(square-iter 1.0 x))

上面的代码阐述的是牛顿法解平方根,看不懂没关系,我用js再写一遍给你看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 嗯,好像用缩写了……
* 这不重要,毕竟作者会懒嘛,领会精神……
* 而且即便这么懒的作者(对不起了,Harold和二位Sussman老师),他们在觉得你可能看不懂的地方还是没用缩写不是吗?
**/
function sqrt(x) {
return squre_iter(1.0, x);
}

function square_iter(guess, x) {
if(good_enough(guess, x)){
return guess;
}else {
return square_iter(improve(guess, x), x);
}
}

function improve(guess,x) {
return average(guess, (x/guess));
}

function square(x){
return x*x;
}

function good_enough(guess, x){
return abs(x-square(guess)) < 0.001;
}

//average和abs没有定义,但是看名字也知道什么意思了,前者是求平均值,后者是求绝对值

这样写的代码,每一个函数都看做是一个接口,在读的时候不需要深入往下打开就可以连接代码,比如我们读square_iter的实现的时候,就可以看懂,他的逻辑是,猜的数字也就是 guess,如果 good enough 了,就返回猜的数字,否则继续迭代,迭代的之前,把猜的数字根据x提升(improve)一下,作为新的猜的数字传进去。至于怎么算 good enough ,怎么进行的提升,我们不需要了解就能看懂这个主干逻辑。我们看到接口就知道每一步具体做了什么事(也就是do what),至于怎么做的(也就是how),当我们需要了解的时候,再打开看。这种不需要了解就能理解主干逻辑,需要了解下层实现逻辑时再打开看的效果,又被称之为抽象屏障。即便是在面向过程的场景下,我们也可以靠函数的名字就完成了一层抽象屏障,使得我们不需要关注下面的实现,也就屏蔽了复杂度。所以有意义的接口不仅仅在面向对象的场景下有用,在面向过程的场景下,依然是有价值的,而且即便是面向对象的场景下,这种对函数名做抽象的实践也是很常用的,毕竟面向对象也都是要给“方法”起名字的。

最后的话

这个做法看起来简单,但是行业里这么做的人其实并不多,到处的文章都在说要写好文档,但其实,任何一个长期存在的系统(也不用太长,三年就很长了),我们最终都是要通过代码来了解领域逻辑的,因为文档都过时了,人员也流失的差不多了。相对于文档的过时,代码其实是存在一个契机持续提升可读性的:即每次修改的时候。每次修改的时候都需要先读懂代码,那么每次读代码的时候,发现不好理解其实都是修改的良好契机,维护的越多,代码应该变得更好读。所以抛弃代码本身的可读性去追求文档的面面俱到是不可持续的,只有提升代码的可读性从而提升软件的可维护性是一条正路,为了这个目的,有意义的接口是我们应该追求的一种实现模式。

参考资料

[1] 实现模式/(美)贝克(Beck,K.)著;李剑,熊节,郭晓刚译.修订本.北京:人民邮电出版社,2012.12
[2] 领域驱动设计:软件核心复杂性应对之道/(美)埃文斯(Evans,E.)著;赵俐等译.2版(修订本).北京:人民邮电出版社,2016.6
[3] 《中国学科发展战略·软件科学与工程》
[4] 《软件工程知识体系指南》
[5] 《计算机的构造与解释》

什么是开发工序管理

所谓的开发工序管理,就是把开发人员的工作步骤管理起来,以期其产出的代码达成组织期望的代码质量。

为什么需要工序管理

DevOps的理想和现实面前,有一个开发工序管理的鸿沟。
很多组织落地DevOps时,只把工具链、平台都搭好了,但并没有项目在这个平台上按照DevOps期望的节奏运转,也就是不能快速发布,价值并不能持续的流动,基本上都是卡壳的。
这是由于开发人员不能生产出可以快速回归的软件,自然不能快速发布,就算是快速发布了,也是可能有质量问题隐藏在其中。

这个问题持续不能解决的原因是因为开发人员缺乏开发者自测试的能力,需要规模化写测试的能力,否则我们无法形成可快速回归验证的软件。如果不能规模化写测试的能力的话,DevOps的落地最后就是开着敞篷跑车上太空,就算你飞上过去一次,也不应该有第二次。

规模化之前,我们先看一个胜任的个体是什么样,我们横向比较一个开发者自测试能力强的和一个这方面弱的程序员的工作就会发现他们的工作步骤完全不同。

强的开发人员能够按照架构设计把需求分解为可测试步骤组成的工序,并能按照工序高效得写出有测试保护的代码。弱的人则只能拆解为写代码的步骤,至于能不能测试,就不再考虑范围之内了。尽管他们后续的工作时间都差不多,但是产出的代码一个有测试,一个没有,而且也很难加。

而在我们过去的过程改进经验中,能够按照架构设计把需求分解为可测试步骤这件事,并不容易,需要特定的胜任力——分析型思考和少量的概念性思考,而这两者在软件开发人员当中是很稀缺的。于是导致前一种工序很难被推广开来,而不解决这个问题,我们产出的软件始终是一个难以快速回归的软件,所以我们需要工序管理,可以帮助我们规范开发人员的行为,规模化的使开发人员可以高效的产出有测试保护的代码。

怎么做工序管理

在讲解怎么做工序管理之前,首先要讲一个概念:杠杆率。

杠杆率就是我们认为胜任的高级人员(后面称之为Sr.)和不完全胜任的初级人员(后面称之为Jr.)之间的比率,比如一个Sr.带着5个Jr.杠杆率就是5,称之为杠杆率高,反之,如果2个Sr.带1个Jr.杠杆率是0.5,称之为杠杆率低。

那么介绍这个概念的目的自然是,在杠杆率不同的情况下,工序管理的手段是不同的。

如果杠杆率比较低,也就是初级人员是宝贝,高级人员团队里到处都是,那么就不用费劲了,给初级人员设置个导师,一个不过瘾还可以多设置几个。一副江南七怪教郭靖的架势(这个比喻应该还不算过时吧……),一群老师盯着你输出的代码耳提面命,我就不信你学不会。
这也是为什么早期的工程实践方法论里,较少提到这个概念的原因,因为在当时的西方社会里,低杠杆率是普遍现象。

然而在中国,这个现象则完全不成立。我们这个行业以25%的增长率暴增了这么多年,现在的情况是,不但杠杆率高,而且Sr也未必胜任。这个时候我们就需要针对目前的现象进行更适合中国特色的工序管理。

首先我们要聊一聊架构,因为架构是指导如何进行工序分解的,对于我们怎么做日常编码工作是有强烈的指导意义的。如果架构本身就不是容易测试的,那么对应的工序也就很难形成,或者其中某些步骤的测试成本过高。由于我们这个行业发展过快,我不确定我们现在的架构师们,是否在这件事上是普遍胜任的,从现状来看,普遍不胜任可能更接近事实。可能工序管理的第一步还是先帮助架构师搞出可测试性比较高的架构才行,起码别跑个测试还要上硬件或者部署到UAT环境,那就不太合适了。

接着,我们需要给我们的Sr人员进行培训,让他们的可以做到“按照架构设计把需求分解为可测试步骤组成的工序,并能按照工序高效得写出有测试保护的代码”。然后由初级的人员,按照工序编写代码。

接着,就是工时管理,我们拆分出的每个任务,都应该在一个固定的时间内完成,如果没有在固定的时间内完成,我们则视为是一个事故,需要针对该事故做分析并提出改进计划。通常就可以发现要么是工序拆解的有问题,要么是初级人员的能力有问题,都可以针对性的进行能力建设。

落地的细节

架构师是否拆解工序

架构师首先应该学会这件事,毕竟他要能解决工序如何拆分的问题。所以还要打个样,看看工序怎么拆分,是不是可以拆为生产可测试代码的工序。然后他自己也要写一下代码,看看工序映射为代码的过程是否会遇到障碍,有时候自己写一下会发现之前很多关键细节没有想清楚,这对于架构师也是有好处的。

Sr首先自己要胜任

这点其实也不是很常见的一件事,毕竟不胜任在我们这个行业太普遍了。Sr首先自己要能拆出来可以测试的步骤,而这一点其实并不容易。首先是对于很多人来说,能够分解为可测试的每一步就已经很挑战了,而当把这些步骤映射为代码时候,我们会发现更多的人根本不能按照自己所划分的任务进行正确的映射。写代码的步骤可能和划分的步骤完全对不上,更不要说每一步都可测试了。这里是能力建设的一个关键点。

优化无止境

通过对工序的完成时间进行追踪,我们不但可以得到高效的个人,还可以发现效率的瓶颈,进一步的改进工序,比如开发一些工具。

工序管理是否复辟了软件蓝领的概念

先说结论,并不是。因为软件蓝领是只写代码,把程序员压缩在编码在一个小环节里面,不端到端考虑问题,不管需求,不考虑怎么测试。
而工序管理只是压缩在了一个更小的问题域里,但是这个问题仍然是需要理解、需要思考、需要测试的。
我们要承认普通开发人员可能在当前阶段的认知水平不高,所以我们的工序管理是一个生手和熟手配合的工作方式,是追求一种更大杠杆率的生手和熟手配合的场景。
通过高级别人的人一种更高效的传帮带的方式教会普通开发在当前业务下、当前架构下进行工序分解的能力。当生手的认知能力变得提高之后,他可以自然的去解决更大的问题,而不是把开发人员从高级工作中隔离出来,异化为一个可以随时被替换的螺丝钉。

比起软件白领与软件蓝领的关系,高级开发人员和初级开发人员更像师傅带徒弟的关系或者是教练和运动员的关系。

前言

本篇开始针对前文《软件开发人员培养的挑战
提到的挑战试图进行解答。我们要明白,培养其实是个辅助学员学习知识的过程,那如前文所说,知识量很大又很容易更新换代,所以首先需要明确的一件事就是——什么值得学。根据我的经验需要学习的有两个关键内容:思维框架和具体知识。
这两个东西其实很难分得清,或者说他们是迭代交替存在的,思维框架中有知识,知识中有思维框架。但是还是有必要对他们做一个区别介绍,毕竟如果不区别,思考问题时容易搞错问题域。这种区分是为了更好的改造世界,而不是为了更好的解释世界,所以才会出现框架中有知识,知识中有框架的情况。不过在工程的领域,过分追求更完美的解释世界是没有太大必要的,因为工程师的职责不是解释和赞美世界的,工程师的职责是撸起袖子改造世界。

下面我们一个一个介绍,先介绍思维框架后介绍知识。思维框架这种东西较少有文章详细介绍,所以这里我就说的深入一点。而知识部分网上的内容就很丰富了,我就大概讲讲关键点即可。

思维框架

本文介绍的思维框架是通用于所有编程场景的思维模型,是用以解决问题的通用模型,基本上放在所有的编程场景下它都是适用的,但是属于大的框架,可以用以在编程中作为大的方向的指导方针,而不是一步步解决具体问题的框架。虽然听起来比较抽象,但思维框架本身也是非常重要的,因为我在过去的工作中发现,好多人是靠本能思考,没有思维框架的指导,这种情况下的很多人尤其是不太资深的人,都容易陷入无思路头脑一片空白的状况;或者不能还原来看其实复杂的程序也不过是简单程序的叠加,没有什么神秘的,一遇到问题就会过分戏剧化思考而无所适从的状况;有时也没有办法识别自己现在是卡在哪种情况 ,没有办法求助。所以先介绍比较大的思维框架我认为是比较重要的。

如前文所述,软件开发是如下的工作:

  • 把你的业务问题转化为数学问题,让它的规则逻辑自洽,然后用代码实现
  • 如果它的规则过于复杂,人力不能判断逻辑是否自洽,进行抽象,寄希望于抽象之后逻辑自洽
  • 如果抽象之后依然过于复杂,不能判断逻辑自洽;或如果进一步抽象将变成过度抽象,从而无法判断是否可以落地,那就切分。寄希望于在一个问题子域中,解决方案可以逻辑自洽,不同的问题子域的方案之间可以映射以合作解决大的问题域

那么上述三项工作,哪个最重要呢?很多人可能觉得第三个最重要,第二个次之,第一个最简单,也最不重要。但根据我多年工作经验来看,第一个是最要命的。第一个确实最简单,简单到跟篮球运动员的运球和投篮一样简单,然而最简单的事情不意味着大多数人都做得很好。从能力角度,最需要构建的能力,其实并不是什么高大上的能力,恰恰是最基本的能力。而这些最基本的能力是大多数人所不具备的。这就好像人人都在谈论上乘武学,然而大多数人马步都扎不牢。

思维框架第一级:输入输出

由于软件开发是把业务问题转化为数学问题使之逻辑自洽。那么首先,软件要合逻辑,其基本表现是可预测,也就是可测试。也就是说,确定的输入,一定要得到确定的输出,不然就是玄学。那么软件开发人员首先具备的能力就是要能够把程序从输入输出的角度进行思考,把输入输出想清楚再开始工作,这件事情在软件行业竟然不是通识,也是很悲哀的,大量的人根本没想清楚就冲进去一顿乱搞。
由于软件本身的复杂性和人的易错性,可以把一个大的具体的处理过程,拆成一个个小的处理过程,分段验证以隔离人的错误是可测试性角度进一步的要求。举个例子:
某段程序的职责是处理输入,得到一些数据,然后提交。这段逻辑中,最后有个函数叫commit。整段有两种实现,一种是commit之前先把处理后的数据结构化好,然后再传给commit进行提交。另一种就是直接在commit 内构建数据,然后提交。二者相比后者比起前者更具可测试性。中间处理过程,如果比较长,也分步切割,使得每一步都可以单独测试,就比只有一个大步,更具可测试性。

也就是说能否拆解出更细粒度的具备可测试性的程序是保障程序合逻辑的基本要求。(这也是软件是否可信的关键点之一,如果无法细粒度的观测软件,那么软件的行为就是个黑盒,无论如何说不上可信。)

这是很重要的思维模型,很多人思考软件都是凭本能,最后变成了王宝强的烧饼,必须他来做才能做出来(源自于泰囧里,王宝强饰演的角色说我的配方就是我来做,我来做就是我的配方)。从社会角度还是企业角度都不能承受这种风险,同时,对于初学者他还不具备做好的基础,凭本能只会一团乱,所以建立起输入输出的思维模型是重中之重。

思维框架第二级:抽象

输入输出思维模型,建立起来之后,我们会发现这个分析是没完没了的,很容易陷入细节,最后设计搞了半天却还没开始写代码。随着要构建的软件越来越复杂,这个时候我们需要抽象来加速我们的思维过程。也就是我们前面说的几点的软件开发到底是什么工作的第二点,也就是抽象。

所谓抽象就是对实现细节的封装,计算机从底层到上层,实在是太复杂了,没有人可以同时思考这么多的细节,所以必须通过封装来完成对思维负担的降低。当我们抽象出一套概念后,我们基于这一套概念来思考,而不是基于概念的实现来思考。通过抽象封装,我们可以降低思考的复杂度,我们对这部分的思考就简化了,可以释放出来思考更复杂的业务。

编程非形式的讲,处理两样东西:数据和过程。那么抽象自然是对两种东西的抽象:数据的抽象和过程的抽象。

首先来讲是数据的抽象,数据的抽象就是把相关的数据封装在一起,给这一堆数据起个类型名,比如把username、email等封装一个数据类型叫User。这样的一个直接收益就是我们思考输入输出的时候可以思考得内容更多一些,因为通过数据的抽象封装了很多细节之后,我们需要思考的细节就变少了。与之相反的操作,在日常工作中,好多人都习惯用Map(有的语言是Dictionary)存取,避免起类型名,这是个反模式,因为起名字费劲而不起名字就是在放弃抽象,就是在放弃驾驭复杂问题甚至复杂方案。另一个问题。

数据抽象是一种极其常见的手法,在面向对象里,我们管这个叫封装,即便是在面向过程或其他编程范式中,这个动作也是有的,比如面向过程里通常也都有结构体等方法来自定义类型。只是面向对象通常是把数据和过程封装在一起作为类。面向对象的这个做法恰恰揭示了数据封装的关键点,那就是数据的封装是跟过程相关的。面向对象的这个手法可能粗暴了一点,但是不管有没有这个手法,数据都是跟某些过程有比较强的相关性,跟有些过程有比较弱的相关性,其他绝大部分的过程完全没有关系。这种相关性是抽象的关键依据,然而大部分的人也都是不会识别和处理或者就只是凭借本能识别和处理。

其次过程也需要抽象,首先前面说到的围绕自定义的类型进行相应操作的过程,本身这些过程就是很多细节,它会打开这个抽象的封装,然后进行一系列的计算,做这么多细碎的事情的过程自身必须有个名字,也就是过程名,这个过程名本身就是一种抽象。这种抽象使得我们不会去关注其中的细节便可以进行思考,于是可以驾驭的思维内容又可以再一次变多。当然前提是我们进行好的封装不会随意的破坏,这也是一种反模式,很多程序员对抽象是很无意识的,前面数据的封装没有意识,到过程里用的时候也没有意识,封装拆封没有任何规律,很随意,这代码写的就非常难以维护,思考的时候必然也是混乱的从而难以交流和配合。

那么什么样才是有规律的呢,或者说好的做法呢?那最好的方式就是复用人类已有的思维规律——语言。一旦完成数据的抽象,并且围绕封装好的数据可以进行相应的操作的过程的抽象,我们就会获得一套语言体系:数据的抽象往往是名词,过程的抽象往往是动词。在过程中,数据被拆开封装进行计算,计算完后返回另一个封装好的抽象,交给另一个封装好的过程进行处理。围绕着这些名词和动词,还可能延伸出其他词性,比如排序可能传入一个表达正序还是倒序的数据抽象,这个数据抽象就算是副词。还会演化出连接子句的连词,比如过程的进一步抽象高阶过程。通过对语言体系的一一映射,我们就可以使用自己本来就已经极其熟练的思维技巧来驾驭极其复杂的问题及其解决方案,从而提升思维的效率的同时保障精确性甚至提升创造力。这也是跨界能力存在的背后机理。

语言的描述是很重要的一种思维模式,不能用人类语言描述的东西基本上也没法用软件编写出来,人们能力所限不会描述是一回事,但是只要是能写出来的软件,必然是可以用语言描述的,毕竟计算机语言也是一门语言。

当然整个过程也不会那么一帆风顺,有时候抽象之后的思考会有一些偏差,漏掉一些关键的细节从而导致落不了地。不同于其他工作,前文说到写代码这件事不遵从差不多原则,不能差不多就行,差一点整个程序也运转不了,所以它的抽象要求格外严格。有丝毫的偏差,这个抽象就是空中楼阁,或者变成技术债存在于代码中。由于软件的每一个版本之间不存在推倒重来,之前的每一个代码都是后续开发者必须背负的负担,哪怕删除一行也是需要一定成本的,有时候关键的技术债,消除的成本有的时候并不比重新造一个系统简单,而重新造一个系统也不能保证不再有此类技术债存在其中,所以开发者必须面对技术债带来的两难困境。这也是这个行业为什么痛恨PPT架构师的缘故。

思维框架第三级:边界

数据和过程的基本抽象仅仅是入门,比起输入输出模型,相对来说就好一点,但是软件进一步复杂下去,就要开始看第三点了,也就是抽象层次问题和限界上下文问题。

很多人学会了抽象,就像建立起一种可以解决世界上一切问题的统一抽象。这个实际上最后并不能解决编程的问题,因为你已经有一个可以解决编程世界里一切问题的统一抽象了:通用编程语言。你一直抽象到最后,只会得到另一套通用编程语言,于效率提高无益。学会一项技能,有节制的控制和使用是很关键的,所以我们需要理解边界对于解决问题的重大意义。

前面说到,我们人脑是“内存”非常小的一种“计算设备”,而软件的细节太多了,从上层的抽象到底层的机器信号,这么多复杂的细节,如果都去思考,不用很长时间,大脑的“内存”就溢出了,根本思考不下去,所以我们需要抽象。而抽象并不一定能解决问题,问题的复杂化是没有止境的,往往抽象之后,逻辑依然复杂,信息量可能依然很大,大脑依然崩溃。

所以我们需要建立抽象的分层来隔离掉细节和更底层的噪音,才能解放大脑来关注更重要的事情。

这个技巧不仅仅是在从软件到底层的角度需要,复杂系统的架构的设计、业务逻辑的拆分、日常的任务分解都需要这个技巧,否则很难高效的完成工作,就算完成设计也是混乱的。举个例子:

我们需要写一个parser解析命令行传入的若干参数。传递给程序的参数包含两个部分:

  • flag: 是一个字符,在传入给程序时必须前置一个短横线,flag后可以有0个或1个value
  • value: 跟在flag后的具体值

parser能够接受一个schema来指定期望接受到的参数格式,其中schema会指定flag和value的> 数量及类型。当程序将参数传递给parser后,parser会首先检查传入的参数是否符合schema的> 要求。例如,程序希望的传入参数如下:

-l -p 8080 -d /usr/logs

那么对应的schema要求应为:

  • 参数可以有三种flag: l, p, d
  • l是一个布尔标记不需要传入对应的value,当传入参数中有l时代表true,否则为false
  • p的value类型为整数
  • d的value类型为string

假如schema支持的flag没有在参数中出现,那么其值为默认值。例如布尔值的默认值为false,> 数值的默认值为0,字符串的默认值为””。

在这种级别的需求面前,比起抓紧开始抽象概念,首先要做的就是分层。因为传入的是字符串,先把字符串变成结构化数据是一层。结构化后的数据再转化为抽象概念的自定义数据类型又是一层,校验自定义数据类型的实例的值是否合法是另一层。起码要分这几层,如果是更复杂的需求分的层次还会更多,比如后面还会有执行命令又是一层。通过分层使得每一层的抽象都是简单的,而不需要进行复杂的抽象,既降低了我们的设计难度也降低了我们的实现难度,最终也会减低我们的维护难度。

这例子告诉我们在接到需求的时候,不要着急的考虑具体概念的抽象,先考虑分层。而这种意识,对与很多人是欠缺的,他们有时候没有这个意识去思考,有时候意识到了,不知道该怎么思考。

其实思考这个事情的思路跟前面的思路一样,就是个语言问题。当我们把数据从字符串转成结构化数据为一层的时候,对于这一层来说,他知道的只有-,横线后的字符,空格,几个字符这些概念。它就干一件事,把字符串按照一个格式拆开,组成某种简单的无噪音的格式,交给下一层继续处理。并不需要知道boolean之类的类型,也不需要知道什么默认值,至于flag是否支持就更没有必要知道了。对于这一层来说,它知道的概念极少,做的事极简。这样它领域内的抽象就不会异常复杂。

一个层次只用一组概念,互相作用。如无必要,勿增实体。这就是通过边界的划分来限制概念的爆炸性增长从而避免复杂化的技巧。

这个技巧可以用于分层,也可以用于分块。分出来的不管是层还是模块,他们都遵从同一个原则,只有一组有限的概念,这种东西我们在语言中叫做一个上下文,在软件开发中,我们也叫它一个上下文。在一个叫ddd的开发方法里,它有一个叫限界上下文的概念就是此类东西。

有时候,你不但分块,还会通过把问题分解道不同的时间维度上来解决,比如类似宏,采用编译时拼接来解决产品线问题。

基本上软件设计就是在做上下文的切割,上下文的切割有很多典型的场景,我就不在这展开说了。一个恰当切割的上下文会最大限度的降低设计的难度、避免后续实施中的坑并且可以有效的控制需求扩展对架构的冲击。

在这之外还有其他思维框架及对应的技巧,比如sicp中有个例子,由于是基于pair这个数据结构实现的list,tree等数据结构。所以一个可以递归遍历pair的函数就可以遍历所有的list、tree。也正是因为我们做了良好的设计,使得在一个层次上可使用的操作,跨越不同的层次依然可以使用。这会带来生产力的提升,毕竟一个函数的适用范围变大了。

这种换个角度看问题的思维模式(list,tree其实都只是pair里放着另一个pair的数据而已)确实在很多时候是很精妙的设计。但这种技巧过于精巧并不是绝大多数人容易掌握的,自然也就不应该期望于绝大多数人掌握那种过于精巧的技巧来完成软件,毕竟软件开发是个团队工作,人也是来自于市场,受制于现实情况,我们不是在真空中做软件。

知识

知识部分,前文说到我们是个可怕的行业,首先知识量大不说,还很容易过时。然而又不能不学。毕竟只有思维模型,不具备具体的知识,也只是坐而论道的高手,我们需要培养的是能撸起袖子实干的人才。

这样的情况下,我们自然要对知识进行合理的分解,进行有策略的学习。识别哪些知识是初期必须的,在具备了初期的知识后,那些知识又是长期可以保值的,那些知识是会快速贬值的。不管对人来说还是对组织来说,投资快速贬值的知识是很危险的。

作为初学者来说,一门语言的语法是必须要掌握的,然后就是一门语言的各种库。这些都是必要的,除了学会基本的东西外,主要是知道语言能干什么以及怎么干。

ide也是要掌握的,在ide导入工程、更改依赖、执行、启动服务、调试以及日常的文件操作和编辑快捷键这些是比较关键的,其他的都算是细节。

接着就是代码版本管理的工具,基本的提交、同步、拉分支、合并、解决冲突之类要会,当然这些IDE也都有相应操作。

然后就是具体的编程框架了,没有编程框架的辅助,绝大部分人都没法从事具体的应用开发。所以编程框架的学习还是很重要的,有了编程框架才能更好的从事具体应用的开发。

但是以上的学习中有会有巨大的坑,那就是我前面说到的,知识是会以极高的速度过时的。比如框架,不管前端框架还是后端框架,不知道有多少都消失在历史的烟海中了,还有多少人用得到如何用Struts框架来响应HTTP请求?有些语言的开发框架倒是没啥变化,然而整个语言整体退出某些开发领域的主流地位变得非常边缘了,像Rails尽管曾经风靡一时,你现在招Ruby程序员也不好招了。即便留存下来的,更当时的用法也有了不少的变化,现如今的Spring Boot跟当年的Spring对只学表面的人看来也是完全不同的东西了。

还有人非常依赖图形界面,太依赖IDE,换了IDE就不会干了,这也是很糟糕的。基于图形界面的知识是贬值速度非常快的,不但IDE如此,其他各种工具都是如此,我之前面试了一个人,他对于他的知识只能用他擅长的软件界面来编码的,他回答怎么做开发都是画个大概的ui然后说自己怎么做。这种知识是极其容易贬值的,软件界面一变化他的知识就过时了,要重新学习。从win32到今天的windows都变了多少呢?更不要说其他的工具,更是变化的很随意。

从工具角度讲,GUI(图形界面)类型的工具就不如CLI(命令行界面)类型的工具保值,

虽然框架会变,然而背后的思想及其相关概念往往是比较稳定的,思想通常是不会过时的,只是实现方式会有变化。跟着这些框架、工具、学习思想及其相关概念还可以学习前文所提到的抽象能力边界、识别能力,甚至可以学到一些前面提到的高级的抽象技巧应用于将来复杂问题的解决。

基于这些概念的工艺类的工程实践也是比较长久的。常见的TDD、CI等极限编程的工程实践外,基于不同的框架也会有相应的手法,比如React文档中就有一篇《Thinking in React》,即便是脱离了React也是很有价值的前端工程实践。

设计模式值不值得学。简单的答案是值得,其实模式化是一种很重要的东西,凡是能模式化写代码的地方都应该模式化,只有模式化的写代码才是专业的态度。毕竟我们的代码是要被多人维护的,你写代码一会一风格,不遵从行业的常用模式,换其他比较严肃的工业领域,说在犯罪也不是太过。模式化这个东西用于艺术创作固然可能不是好事,但是对于工程,这是很大的好事,毕竟它可以再挣提升提升效率同事却不损害质量。

但是提到模式,只想到gof做的23种设计模式是有点狭隘的,只要是模式化的编程方式都是应该值得学习。

比如说过程存在一些模式化的抽象,举个例子我们进行的一个培训里,我在一开始告诉了学员7种过程:map、filter、find、anyMatch、allMatch、Sum、split(当然除了split和map,都可以统一称之为reduce,这就有点过度抽象了,我就没提),我告诉大家我们后面的培训只需要复用这七种过程就能完成几乎所有的作业,同时由于这些操作是通用的,于是我们的实现方式也会比较模式化,希望大家体会。然而很多人后面做的时候,还是各种乱写,并不能把问题拆解然后抽象为这七种过程,通过它们的组合来解决,或者找不到他们中的最简组合来解决,这种思维也是要练的。

很多编程范式也是值得学习的,比如OO、函数式编程等。编程范式通常天然便带有一些GoF设计模式,有的时候还会带有一些GoF设计模式之外的模式。模式很多,学习模式不是为了学习金科玉律,主要是学习先贤们思考问题解决问题的方式。

小节

以上就是软件开发人员最需要学习的两大部分内容,希望对于致力于培养软件开发人员或想要自己培养自己成为软件开发人员的人有所帮助。

传统的三层架构

传统的web开发都有典型的三层结构,从上到下依次是controller,service,dao。controller负责http的请求和响应,以前还负责调用jsp等模板渲染引擎来渲染页面返回html,现在基本上都前后端分离了,不再干渲染页面的活,而是返回json来扮演web api了(还有用rpc的,也是一样的承担api的职责)。

service负责业务逻辑,dao则是最下层负责与数据库交互。

问题

这三层看着很美,本来是想要解耦的但实际上确是耦合在一起的,主要是他们用的模型类,也叫实体类往往都是一套,这就是他们的耦合点。这么搞就很坑了,现代spring开发框架下,带来的就是各种annotation的滥用,json的annotation和持久化的annotation都标记在一个类上,将来想拆都不好拆,平时想改都不敢改,实现一个新的需求变化,因为背着各种关系和不同层的限制和约束,基本上就是带着镣铐跳舞。同样由于边界的划分不清,数据模型之间的循环引用和因此还要采用的trick手法来切断都是开发的负担。

抛开模型造成的耦合,三层自己也经常搞错。service层和dao层经常逻辑混在一块。业务逻辑跟sql混在一起,想换个nosql之类的实现都不好换。想做数据库迁移也不好迁移。通常项目刚开始的时候会觉得技术迁移是未来的事,等过两年就是每天都要考虑的事。controller里写业务逻辑也是很常见的,本来该在service的写在了controller,最后大量的重复代码还看不清操作的核心逻辑。同样也是迁移时的困难。

新的架构

在上面的问题章节里,我们聊到的问题基本都可以用边界不清来表述,如果用更精炼的词就是耦合。耦合大家都知道是不好的,然而却还是写出很多耦合的代码,到底是为什么呢?主要还是因为没有相应的概念帮我们区分代码都分为哪些类别,都有哪些更具体的概念来识别职责,这种概念越少,越模糊,越容易写出耦合的代码。

新的架构就要提供一些更细节的概念来帮助我们解耦。所以这里我们介绍一个基于clean arch的改版架构,它的主要思想都是源自于Clean Arch,但是有些地方也使用了六边形架构和洋葱头架构的名词,毕竟很多人都说,这三个架构本质上是一回事。

首先了解一下adapter、usecase和domain这三个大层,如下图(domain指的是最内核写着entities的部分,往外依次是use case,adapter,蓝色的部分就是实际的外部世界了)

image.png

在最外面的controller那些属于adapter,它们主要负责表现层的渲染,跟外部程序的交互,比如响应HTTP请求。adapter自己也是分组的,有时候不同的协议接口都会算作不同的adapter,比如HTTP的API和RPC的API,通常用于一个服务以不同的方式对外提供服务;有时候场景不一样也算不同的,比如web api和web page会被我归成两个adapter,通常用于遗留系统里部分页面还是后端渲染的时候;有时候不同的技术也是不同的adapter,比如spring mvc和Jersey,通常用于技术迁移过程中;

有一种建议是adapter也要与具体的框架分离,这个我觉得大部分情况下adapter这层不是很复杂,全部重写可能更合适点,这么做最大的好处就是迁移adapter技术实现时更方便。这么做我觉得会适得其反,毕竟这种做法需要的设计要求太精细了,对于绝大多数团队来说做框架分离这种事把自己埋了的可能性远大于在将来技术迁移时成本降低的可能性。对于绝大多数团队,只要能做一个api一个api的替换,已经是不错了。要想做到这个,使用一般的开源框架,比如spring mvc或jersey并不难,但是要是使用公司内自己做的就不一定了,很可能它不支持运行时两种adapter同时对外提供服务,直接从根本上把你用其他框架对外提供服务的可能性就给掐死了,这样的内部框架使用前请三思。

不同于ddd里的分层架构,clean arch里面,负责做数据库存储的持久层也是一种adapter。 当我们做了这样的设计后,就可以把框架相关的annotation彻底清除出domain层,于是整个domain就真的变得干净了,配合上ddd里的一些概念解耦后,技术迁移也会变得容易。具体的内容我们后面聊。

usecase则是以前的service,通常是我们应用逻辑的编写处。service这个词不是用以表达一个分层的好词,因为我们做的都是商用软件,一切都是service。在ddd中分为了application service和domain service,通常是说领域相关的属于领域服务,应用场景相关的属于application service。这些描述太抽象了,还都叫service实在是容易混淆,所以我们现在习惯把application service这层都叫成了usecase,usecase比application service好的地方在于,service你会不自觉的用业务实体的名字做前缀,比如UserApplicationService(甚至有的人因为包名上已经有application了为由,干脆就叫UserService),而use case你会不自觉的用业务场景来做前缀,比如RegisterUseCase。这样的话我们就会容易关注到application service的本质:应用层业务场景。当然即便如此,当我们加入新的逻辑的时候依然不能很好的区分放在哪一层比较合适,这点我们稍后再深入聊。

接下来就是domain层了,这个概念最早见于ddd中介绍的分层架构,里面定义domain层是用来表达业务概念,业务状态信息和业务规则的。用极限编程派敏捷中的一个实践:代码即文档 来说,这一层的代码所作为文档指的是业务文档,表达的是业务逻辑而非技术细节。这点对于遗留系统尤为重要,我曾经在几个10年以上的遗留系统上工作过,那陈旧的技术和业务逻辑混在一块的味道可是不好受,那个时候是多么希望业务逻辑和技术细节是解耦的。技术的进步是一个永不停息的过程,据说受摩尔定律影响,每18个月就有一代,所以架构师必须在一开始就谋划技术细节与业务逻辑的解耦。那么作为业务文档,由于业务本身的复杂性,势必要面对上下文的切割,所以这里面使用ddd的战术概念来管理业务模型是最好的。

这就是这三层,这三层的要求是,外层依赖内层,内层不可以依赖外层。所谓依赖,在java中指的是import,如果A类import了B类,那就叫A类依赖了B类,外层依赖内层的意思就是外层的类可以import内层的类,反过来不行。这个本来我是觉得很基础的概念,不过由于工作中看到有人认为哪个包在哪个包下面是依赖,我觉得还是有必要澄清一下。

深入聊聊细节

PO、聚合与其他domain层概念

首先说一下实体耦合的问题,当各种标记都标注在实体类上的时候,势必不同上下文是会互相侵入的,也就变成了耦合的状态。虽然有人说可以通过xml的方式避免annotation的侵入,但实际上关键的耦合没有摆脱,你新建实体的时候还要考虑怎么更好的存取,这种耦合其实才是耦合的本质,上下文的耦合使得思考的时候不能专注的在一个上下文中思考,从而使得问题复杂化。类似的例子可以参考marsrover文中没有解耦的direction,虽然没有直接依赖,但是还是要跟command耦合了,跟此处虽然没有annotation但依然跟orm框架耦合是类似情况。

那我们的做法是什么呢,我们抽取出一套专门用于存储的对象我们称之为PO(Persistent Object的缩写),比如UserPO,这个对象只为存储使用,它的构造器负责把所有领域的实体对象转化为对应的PO,比如把domain的User对象转化为UserPO。

这样domain层的模型,像User这种,就不需要关心持久化的问题,不需要为了数据库表的结构扭曲自己本来应该表达的抽象含义。持久层也不需要各种高难度技术来弥合这其中的各种坑,大家都轻松了。

当持久层和领域层解耦了,我们就可以方便的使用ddd里的种种概念,最核心的便是聚合。

所谓聚合就是一组相关领域模型的集合。这一组领域模型的关系是非常紧密,他们聚合在一起统一响应外部操作。通常会有一个类来对外,这个类叫做聚合根。这一个聚合通常要遵守下面的规则:

  • 聚合根负责执行业务规则。

  • 聚合根有全局标识。这个标示说的是业务标识。

  • 边界内的类只有局部标识,在聚合内唯一,这个标识也是业务标识。从业务上讲,一个聚合内的类确实对外理由拥有全局标识的必要,至于数据库id,那是为了存储方便,对于业务上没有意义。

  • 聚合边界外的对象只能引用聚合根,不能持有聚合内对象的引用。这点通常比较麻烦,计算过程中用一下的,只要对象本身是immutable的,我觉得还好。但是作为属性确实是绝对不能允许的。

  • 边界内的对象可以持有对其他聚合根的引用。

  • 删除操作必须全部删除边界内的对象。

  • 聚合边界内任一个对象发生改变,整个聚合的所有业务规则都不能违反。

  • 只有聚合根能直接从持久化系统查询得到,边界内对象只能从聚合根导航。

具体长什么样,要看我们的样例代码里的样例。这里只说一件事,从第一条可以看出,ddd当中是比较推崇充血对象而不是贫血对象的,我也推荐我们采用充血对象,但是不推荐把数据访问操作也放到充血对象上,那就不是充血而是涨血了,毕竟ddd里还有个repository(这个我们在下面讲)专门干这个,聚合跟什么的就不要越俎代庖了。

前面用到所有原本习惯叫实体的地方,我们都用了类或对象来代替,为什么这样用呢?主要是因为在DDD里,实体的概念比其他设计方法里的实体概念要小一点,他把传统的实体分为了两类:实体和值对象。(当然我们日常交流时候,建议除非进入模型的细节讨论时,实体和值对象应该做一个区分)

那么实体和值对象分别是什么概念呢?

实体是符合下列条件的类:

  • 有生命周期

  • 有唯一标识

  • 通过id判断相等性

  • 可变

值对象则符合下列条件的类:

  • 无唯一标识

  • 通过属性判断是否相等

  • 即时创建,用完就扔

  • 不可变

我们这里说的可变是业务意义上的可变,比如一个用户,你可以修改他的email、username、password,你可以改变他的一切值,但只要id是他,那就还是同一个用户,那这个用户类就是一个实体。再比如一个订单的地址,尽管我们常上网购物,都知道有常用地址,但是用户的地址修改了,订单的地址是不可以跟着变的,那么订单上的地址就是一个值对象。而用户的常用地址则是一个实体,因为它可以改。

当然如果一个实体,你设计上就是让它不可变,例如他有历史,每次修改都是生成了一份新的历史,对于旧的实体来说,貌似没有变化,但是你仔细想想,你总要有一个根实体来连接所有的历史,并指明哪个是最新的,这个连接关系的改变,它也是改变。所以依然属于可变范畴。

围绕着这些核心概念,还有几个概念,分别是repository,factory和domain service。factory就是用来构造各种实体和值对象的类,一旦有了聚合之后,可能构造过程略复杂,就会引入factory,不复杂的通常就用不着。repository负责对聚合进行存储通常都是接口,而实现在持久化层,这便是依赖倒置,倒置之后,实际运行时,实现是由接近main的层次的类在构造依赖repository的类(比如某些service)的时候注入进去的。domain service通常就是调用repository的那个类,负责领域业务逻辑,这块有个小难点,就是怎么识别领域业务逻辑和应用业务逻辑呢?我们后面再聊。

聚合的划分,上下文有些时候容易识别,有些时候难以识别。就说博客的修改和发布与用户评论吧。最简单的做法可能是博客下面有评论,评论里有一个属性是用户。这个时候就有几个业务场景要考虑了。一个用户能看自己发过哪些评论,这个时候和给一个博客发表评论,到底是不是一套模型?另外编辑一个博客和展示一个博客及其评论又是不是一套模型?比起问题的答案,意识到这些问题可能更重要。而问题答案,在不同的时期,答案是不太一样的。固然存在客观的上下文,但是在发展的初期,我们通常会刻意的采用一些失真的方案来便于理解。kent beck提出了一个3X模型,按照一个软件的发展分成了3个阶段:Explorer、Expend、Extract,不同的阶段要采取不同的架构设计和开发方法。

一旦一开始比如在explorer阶段就采用不同上下文不同模型的做法,很容易让初学者疑惑,再加上大多数团队的沟通和能力建设都跟不上,反而写出一大堆奇怪的代码。但是如果团队能力比较强,或者我们的沟通和能力建设跟得上,从一开始就采用不同上下文不同模型的做法,其实对于我们平滑过渡到expend阶段是非常有帮助的,尤其是在稍微上点规模的公司,这种过度的速度可能非常快。

一旦两层解耦,那编写聚合的时候就可以不受数据库的限制。从domain层角度讲,一旦做到了这一步,持久层用关系型数据库还是nosql对domain层其实无感知了。可以很容易的做到聚合内数据必须通过聚合根操作,而不用额外操心乱七八糟其他上下文的约束。首先一个就是可以干掉setter,既然PO有专门的模型了,你就不需要为了update之类操作的建立起setter,一切修改权限都只暴露给聚合根,聚合根就可以接管一切业务操作。然后所有的getter都可以是返回一个immutable的对象,因为无法被修改,自然就保持了很好的封装性。最后是引入了上下文和聚合的概念后,可以很容易的消除掉循环依赖(所谓循环依赖就是A类依赖B类,B类又依赖A类,不管是直接的循环依赖,还是间接的循环依赖,比如A依赖B,B依赖C,C依赖A,都属于循环依赖)。

而从技术的角度,技术迁移的时候也可以一个聚合一个聚合的迁移。这里面后者尤为重要,我的经验就是,如果不能把一个大的变化分解为小步,一点点变,这个变化通常不会发生,或者积累到很晚的时候以很大的成本来换取这个变化。不仅仅是变化需要,优化也需要,既然聚合已经自然隔离了业务的边界,那么优化时确定影响的边界就变得容易了,同时优化本身也是一种变化,可以看作从一种技术方案迁移到另一种方案,这种迁移过程中边界的清晰也对于我们验证迁移前后是否逻辑等价有很大帮助。哪怕是日常开发,聚合的存在使得技术的实现也简化了,由于聚合之间通常是业务隔离的,不用存储时还要操心业务上的影响。只要做好技术角度的事务性自然可以保障业务上的事务性。

聊聊service

ddd中把服务分了三层,应用层的,领域层的和基础设施层的。

从这点来看,由于把基础设施层的服务通常是比较清晰的,而且访问数据库的通常不以服务为名,访问其他服务的桩倒是经常以服务为名,不过依然是容易区分的,所以这点上讲问题不大。最麻烦的就是前面讲过的,领域服务和application service的差别不好区分,当然首先是都叫service不是个好事,我们在整洁架构里都叫usecase了,这个问题算是解了。然而那些逻辑应该在usecase里,哪些应该在domain service这个缺乏一个足够清晰的指导原则。而且这里面还有一个聚合根,不是说聚合根也应该有一些业务逻辑嘛,这就更乱了。

目前来讲,我们从实践角度得出了一个被我们称之为两个凡是的指导原则:

  • 凡是能移到聚合根的代码都不应该出现在domain service上

  • 凡是能够移动到domain service的代码都不应该use case中出现

这几个凡是要成立是建立在一些约束之上的,这些约束是:

  • 绝不能把跨上下文的逻辑放到domain service里

  • 绝不能把表现层逻辑放到use case里

  • 绝不能把repository放到聚合里

这里面约束都是比较极端的,你看我们用了绝不这样的词汇,这是因为我们在实践中发现,模棱两可的说法、模糊的边界只会造成工作中的混乱,绝大多数人不具备判断的能力,所以我们采用了极端的描述方式,这样当普通开发觉得无法实现的时候,就会自然的上升到技术负责人或架构师来决断,绝 大部分情况下,只是想错了,所以觉得无法实现,这个就是能力建设的契机。不需要专门的设立什么定期分享,只要靠这些东西就可以很自然的做到能力建设。

前面提到过访问外部服务的桩服务,我在实践过程中也发现这个接口应该放在哪里是个问题。经过我们内部的一些交流,我觉得放在use case层是比较合理的。首先讲,桩服务也是一种adapter,是被use case调用的,那么adapter不应该依赖use case,所以接口应该放在use case。不过我们中也有人认为,随着业务的复杂化,你有可能有些访问来的数据是要被映射为本领域上下文里的一组值对象,然后再参与计算的,这个时候放在use case就不合适了,有一部分接口就要下移到domain层。这点我个人觉得也可以按照我之前的策略处理,先放在use case层,然后用两个凡是的原则考问一下自己,是否要下移到domain去,然后再走use case层和domain层服务切分的那个逻辑就好了,所以并不矛盾。

接口处

接下来我们聊聊隔层之间的接口处,通常接口处都是问题的高发区。

首先说说adapter层和外面这层接口,我看过一些人,在adapter层使用map,只是为了不多写一个类。这种行为是有些糟糕的,我们今天所知道的整洁架构就是最完美的形态吗?不一定,我们过去采用的三层架构本身已经被证明有些问题了,未来也会有新的架构,所以内部结构的调整(也就是所谓的重构)是一个永恒的课题,不得不面对。当你用Map来进行adapter层的数据组合的时候,当时写的是方便了,但是事后重构可费了劲了。因为Map可以随便传来传去,你不知道中间哪里就改了,识别最终的API的长相比较困难,文档本身经常又与实际不符,你实在是不敢做完之后就相信没有问题。

而我们推荐做法是在Adapter层为输入和输出专门建模。输入搞一个类,输出搞一个类,从这两个类里就可以看出你API的数据的结构,那么就可以直接从模型类中搞明白API中数据的结构。这件事很多人嫌麻烦,然而既然代码即文档,文档你都不嫌麻烦,为什么代码嫌麻烦呢?就像前面所说,写的时候觉得技术迁移是未来的事一样,觉得架构调整也是未来的事情,几年之后就是天天都要干的事情,在早期稍微加入一些限制,就能为未来赢得一点转机,望三思。

然后我们看看Domain层与外围,通常来讲use case层直接使用domain层的模型对象,这样use case层会比较轻量。然后Adapter层的模型对象,自然可以依赖Domain 层的模型对象,然后通过构造函数来进行初始化。这样可能会带来一些坏味道,比如长参数列表。但是我们觉得这个坏味道是可以接受的,因为它提供了一个很好的检验机制,当你有一个参数没有传进去的时候,会报错,避免了产生构造时遗漏输入问题。但是有时候数据结构复杂了这种做法也会造成很多人会为了一点方便写出破坏封装的情况,我们前面说了当复杂的时候会引入Factory,但有时候引入Factory问题的本质没有解决,这种情况下为了隔离 可能还会引入专门的TO(transfer object ),放在domain层,外层和内层都依赖内层TO,这样就会更好的保护内层的封装性,同时还有些额外的收获,比如说隔离之后,内层结构就敢更灵活的调整,不会不敢随便调整结构只是因为外层依赖着自己。相应的缺点则是成本升高了,这里面的平衡就要大家自己把握了。

聊聊集成

从业务域角度看集成,首先要定义什么是单元,传递一个命令就是单元,。

那么,在这个领域里,我们的测试应该有多少个呢?

首先,要看我们有多少个变化因子,marsrover面向的四个不同方向状态在接受三个命令时的处理是不一样的。所以四乘三共有12个case。这些都是单元测试。测试的是单个命令的场景。

接着需要考虑场景之间的联动,也是一种集成测试。那你可能需要发带有几个命令的字符串,看看结果是不是对的。

happy path完了,再考虑一下异常分支,没发送命令是一个case,发送了不支持的命令也是一个case。后者好像没说要怎么处理呢,记得澄清。本文就不深入聊这些细节了。

另外,由于本题没有提出最大长度问题,这个我们可以先不测,但是这个是个技术原因必须限制的数字,毕竟单批量发送还是不限制不行的。根据我们上一篇,在实际工作中一定要探讨这个问题,并明确在规格上发布给使用者,不要脑补,如过业务方确实无所谓,就由我们提出一个数字,双方都认可了,放在一个地方。

而在方案域,集成则有不同的含义。不同的设计也会需要不同的集成测试。

先说分层,我会把与外面交互的部分划为一层,也就是把“0 0 N”和“MLRR”之类解析成领域概念的为一层。

解析完之后处理核心业务逻辑的部分再分一层,靠main或XXController之类的调度。

那么这两层的集成呢就是方案域的集成测试了。不过这个时候,你会发现,解析的部分单独测,解析出来的结果执行的部分单独测,最后他们集成起来的部分只需要mock测试测一个用例就好了。

不但测试简单了,扩展其实也会简单,这样我们将来如果命令发生变化比如,开始这么写命令 “M2 L R”也就是移动的时候加入了数字控制移动多少格,为了好解析,我们加入了空格来分隔。这种变化对于执行的来说,并无感知,因为这个变化悲伤层负责解析的部分已经隔离掉了。

聊聊设计

这个题一开始,很多人肯定是一大串if来解决3个command的if和四个方向的if。

写完之后呢,就会开始重构,重构成什么样子的就都有了,比如下面这个:

image.jpeg

这个重构完,通常if就没有了。

但是有很明显的循环依赖,所有人都能改MarsRover,这封装性也太差了。那我们提取一个数据传输对象来提升一下封装性吧,像下面这样:

image.jpeg

这么做循环依赖貌似少了,封装性也提高了,但Direction和RoverStatus还是有个循环依赖,进一步消除一下试试:

image.jpeg

终于,所有的循环依赖都干掉了,但这图看着好复杂啊而且有个问题,那就是Command和DIrection严重耦合,Direction的三个方法明显是Command的延伸,每加一个Command,Direction脱不了也要加个方法。这个设计肯定是不好的。我们可以发现DirectionValue实际上是可以不知道Move的相关概念的,方向之间自然的有左右关系,所以可以改造成这样的设计:

image.jpeg

每个DirectionValue都有一个index,左转可以通过+3然后%4的方式完成,右转可以通过+1然后%4的方式完成,不需要外面的其他概念,实现也很简单。

这下貌似清静了,但实际上Move这个Command里面有四个if还是不好消掉,如果因此搞四个新的子类,会有点啰嗦。这个时候如果采用函数式的方式,把DirectionValue的四个值和对应的移动算法用一个Map封装起来,其实就比较简单了。

这个题做到这,可以加入一些新需求,比如:

新需求1,加一个新指令,如果接受到B指令,那么久会进入倒车状态,这个时候M跟正向的时候是反的。注意,指令的操作虽然反了,但是朝向不能变。比如朝北的M之后,y坐标是减了,但是朝向必须还是北。

新需求2,有一个雷达功能,执行完判断一下自己是不是掉沟里了,如果掉沟里了,就再map上打个记号X(只是表达这个意思,不一定非要是字符串X),后面的rover会忽略走向这个记号的命令(当火星车掉到沟里时,调用init方法创建一辆新的火星车,但旧的火星车还要在沟里,不能消失)。判断无法动的方式目前先用随机数吧,正好练练mock。

新需求3,地图有不同的地形,有的地形能触发无法移动,有的不能。掉沟里是火星车自己在地图上打的标记,不同地形是火星车在来到火星时就知道的地图信息(考虑,X标在哪?真实的地图上吗?真实的世界的映射的地图,和标记了X的地图应该是个什么关系?)

新需求4,车也有状态,有的状态,车会忽略一些指令,比如左转坏了,会忽略左转指令

新需求5,车还分类型,比如Bus,占两格,他的坐标是车头的坐标,但是它左拐时周边必须有可以拐的空间(右侧两格都不能是X),否则会忽略掉指令。

Bus可能会在拐弯时坏掉,

引入卡车,卡车占两格,不同于Bus的是,卡车如果车头处没有坏掉,可以接受特殊指令来脱钩车头,其他车接受这个指令无反应。

(语音输入,标点有些错乱,不太有时间调整,望读者见谅)

(非初学者向,本文的目的是通过一个练习把所有我们要教的内容尽量多的串在一起,让后来进入的讲师可以快速的看清脉络。所以,虽然题是个简单的题,但是对读者要求有一定的敏捷工程实践及DDD相关经验。)

FizzBuzz,是一个经典的TDD入门题目,麻雀虽小,五脏……勉强算全吧。stackoverflow创始人曾经在他的一本书里写道,不要假设程序员都会写程序,招一个程序员来先写个FizzBuzz看看,结果可能会令你吃惊。我当时不信,于是在一个招聘活动上拿这个的一个完整版做了题目,结果也确实挺让我吃惊的,吃惊在哪呢我先卖个关子,在文章比较靠后的地方说(没错,就是为了骗你尽量看完)。后来教人写程序也用这个题用了几百遍了,也见识过各种各样奇怪的错误,所以也是觉得该出个教材之类的东西来讲讲。

我们今天就用这个题目为例,尽量多的多说一些道理。这个题的需求有很多步,就好像软件开发中很多需求是一个版本一个版本迭代出来的,所以我们这个题目也一个迭代一个迭代来:

迭代一

迭代一的需求如下:

你是一名体育老师,在某次课距离下课还有五分钟时,你决定搞一个游戏。此时有200名学生在上课。游戏的规则是:

  1. 让所有学生拍成一队,然后按顺序报数。
  2. 学生报数时,如果所报数字是3的倍数,那么不能说该数字,而要说Fizz;如果所报数字是5的倍数,那么要说Buzz。

不同于凭本能思考,这里我们讲一个套路:我们做软件开发的时候可以刻意分三个问题域来考虑开发的相关问题,我称之为业务域、方案域、实现域。这三个域有什么用呢?

当我们在开发软件的时候,有时会陷入无思路的状态,一旦陷入这种状态人容易焦虑,一卡卡很久却没什么进展。这个时候我们往往是处于一种所谓的unknow unknown的状态。也就是不知道自己不知道什么。新人最容易陷入到这种状态。盯着屏幕看半天。在教学中经常看到这样的情况。这个时候就需要先意识到自己处于这个状态,然后就可以借用这三个域作为跳板跳出这个状态。首先来看看你的问题到底在哪个域,在不同的域要采用不同的方法来探寻你的问题到底是什么,通过探寻慢慢搞清楚了问题,也就开始逐渐有了思路。这就是这三个域的用处。

具体怎么用呢?我们一个个来说。

业务域

首先说业务域,所谓业务只是随便起的名字,用以代指需求。

以这个题为例,我们解这个题的时候,我们先读这个需求,我们会发现一个问题,被3整除返回Fizz,被5整除返回Buzz,被3和5整除返回什么?

这个问题很明显,就属于业务域的问题。

那么业务域的问题,我们通常怎么处理呢?

有的同学就直接脑补了:

脑补一:能被3和5整除,那就是先被3整除呗,那就Fizz。

脑补二:能被3和5整除,那就返回FizzBuzz呗。

那么以上哪个脑补是对的呢?

答案是以上都不对,脑补本身就不对,脑补只是猜测,猜测不经验证就是伪需求。当我们遇到业务域的问题,不要自己脑补,请去与需求方确认需求。

(题外话:当然,你带着两个脑补去找需求方是可以的,甚至于是很好的,因为这样需求方就能更容易的听懂你的问题,比你问被3和5整除返回什么要更具体。这个题目里被3和5整除是很清楚的,但在工作中,提一个抽象的问题,然后跟上两个可能的具体的解决方案也能帮助对方理解。)

确认需求时最重要的就是对概念的理解建立共识,识别概念的边界。前者还好,后者容易疏忽,同一个名词即便在需求当中,由于上下文的不同也有可能指的是两个概念。这块内容本题不涉及,我们找别的案例再来分析。

经过业务域的确认,我们得到了一个完善后的需求

你是一名体育老师,在某次课距离下课还有五分钟时,你决定搞一个游戏。此时有200名学生在上课。游戏的规则是:

  1. 让所有学生拍成一队,然后按顺序报数。
  2. 学生报数时,如果所报数字是3的倍数,那么不能说该数字,而要说Fizz;如果所报数字是5的倍数,那么要说Buzz。
  3. 学生报数时,如果所报数字同时是两个特殊数的倍数情况下,也要特殊处理,比如3和5的倍数,那么不能说该数字,而是要说FizzBuzz。

方案域

方案域就是想想代码写完之后什么样,这就意味着会做一些设计。具体手法很多,复杂的我们找别的场景再讲,因为这个题比较简单,我们这里讲一个简单的,我称之为上下文图,我有4篇文章,重点讲这个:

像机器一样思考(一)—— 宏观的基础

像机器一样思考(二)—— 数据的细节

像机器一样思考(三)—— 穷尽就是力量

像机器一样思考(四)—— 一图抵千言

第四篇文章画的就是上下文图了,这个是我们在简单题目当中常要求学生做的。(通过画这种图的方式,标记出你的程序,如果写完了,应该有多少块?每一块的输入是什么?输出是什么?)

上下文图表达的是代码的静态关系。比如,如果我的代码要这么写:

image.png

那图就是这么画的

image.png

如果代码要这么写:

image.png

那么图就是这么画的

image.png

(按照我文章里的讲法,其实我不应该写输入为无,还有个200呢,不过这算起来,1也算,3,5,7都要算,所以我们在标输入输出的时候,还是要做一些过滤的,过滤的原则就是为了测试的相关性是否强,为什么测试这么重要?我们后面会聊到)

整个这个画图的过程,是对程序的一个拆解,这个拆解的过程实际上也是伴随着设计的。这个图的主要目的就是画出一个一个只有数据依赖,没有过程依赖的小型的上下文。

什么叫过程依赖 如下图所示

image.jpeg

上面的代码每一段if的逻辑执行完都调用了一个continue(类似的还有break和return),这使得每一个if的block都是与外面的for block耦合的,我无法单独把其抽取成一个函数。也就没法单独测试,它的可测试性是一点都不高。

如果我不把程序拆成只有数据依赖没有过程依赖的小型上下文,那么无论是从调试还是测试的角度都会变得很复杂,因而维护也会变得困难。

这段程序另一个可测试性差的地方在于直接输出到了标准输出流里,如果标准库没有给我们提供打桩的方法,那么这个程序就只能人肉测。即便提供了,测试成本也提升了,远不如直接测一个字符串来的容易。所以我们在考虑输入输出的时候,也要考虑输入是否容易准备?输出是否容易获得?

比起画上下文图这个图,得到可测试性高的程序设计这个结果才是我们想要的。当你有足够多的经验后,其实你并不需要画这么简单的一个图,但是你脑子里还是会浮现出这样的结构,按这样的方式去思考程序,这我们的目的就达到了。

当我们得到一个可测试的程序设计后,最后再理清一下,看看每个小型限界上下文的输入和输出,考虑输出的每个数据项是否都能从输入中得到所有的计算因子?输入输出如果没有限制对,那么下层实现域实现的时候就会没思路。

实现域

任务列表

从方案域落到实现域首先第一步要得到实现域所需的任务列表。

图落到实现还是有些变化,我们的图实际上有点像inception当中的用户故事地图。用户故事地图是站在用户使用软件的角度列出软件有什么功能。但是软件毕竟还是需要一步一步做出来。所以上面的故事卡,还要重新搬到看板上去,变成看板上的任务卡,按照实现的角度需要考虑的方方面面排列顺序调整优先级,并且补充相应的技术卡等卡片。

同理上下文图也是需要经过这样一次映射。转化为任务列表。任务列表并不跟上下文图里的图一一对应。就是说我有一个技术不会,我可能要查一查,这也是一个任务。查完之后要做一个试验验证,确定我想要的方式能实现,这也是一个任务。试验完了之后,在真实的产品代码中使用这个技术把需求实现出来也是一个任务。

通常我们把任务就分为这几类:沟通协调(技术类的,非需求类的),技术调研,原型测试,编码实现。随着团队的配合度越来越高,技术越来越熟悉,前三个就会越来越少。任务就会越发趋向于更多是编码实现。

TDD

在编码实现方面,我们前面在做方案域的设计的时候,已经把程序设计成了可测试性很高的程序,所以很自然,我们在落地实现的时候,就可以通过打印的方式肉眼调试,随着我们代码越来越多,我们每当写完一段新的代码块,应该就考虑把所有的都打印出来看看有没有变化,这就叫回归。而肉眼看的方式做人肉回归实在是效率太低,频率也不会高。我们需要把肉眼看转换为自动化的方式,这就是自动化测试。既然我们可以通过自动化测试的方式来进行回归,校验的输入输出在开始之前我们也已经分析清楚了,那我们不妨在开始写代码之前就先把测试写出来,于是就得到了TDD。(TDD的基础用法不在本文范围内,请自行查找。)

很多人抱怨TDD学不会,其实我观察,大部分学生之所以不能使用TDD的方式写代码,核心的原因还是不会把程序从输入输出角度进行拆解。一旦拆解开了,后面的就简单了。

我也发现在编程的时候,很多问题不是智力问题,而是心理问题。

我看见很多同学很喜欢一口气写一大堆代码,然后慢慢调试。如果他们真的有过人的才能,可以一次性写对,我觉得也没什么。然而事实是并不能。反而浪费很多时间。

究其原因还是不会改程序,所以想着一次性写好,为什么这么说呢。你会发现他们基本上不考虑输入输出的具体格式,脑子里有一个模模糊糊的感觉,就开始写实现了,到实现完为止,程序都执行不起来,执行起来之后,因为函数已经很长了,中间出了错误,准备数据也不好准备,于是要改半天,于是更害怕执行了,于是更想一次性写好,函数就更长了。由于不会思考输入输出,也就不会拆子函数,因为大的都没好好想,小的子函数也不会好好想,函数的输入输出没有分析清楚,拆了子函数因为作用域的问题没想清楚,所以想一个函数写完。或者乱拆了子函数,然后就开始各种加全局变量。总之就是因为不敢改,所以把犯错的范围越积越大,故障点越垒越多。越是这样就越不敢执行。因为一执行就更肯定是报错的,一旦查错呢,因为代码太长又害怕查错查的把写代码的思路忘了,于是又强化了一次性写完的行为。

整个这套我们称之为基于本能的行为模式并不是一个理性的结果。反而是一个感性的结果。所以我们教的这些实践并不是单纯的解决智力问题,相当多的部分也是在解决心理问题。

与这套基于本能的行为模式相反,我们教的这套以TDD思想为核心的行为模式,有意识把代码拆成小块,自然可以小步试错,可以小块验证,也就可以保证实现的过程中出了问题,也可以快速的定位问题。哪怕你不写测试呢,你打印也比别人调试快啊,你单步调试也知道每一块干什么,另一块跟这个不相关,你就可以快速跳过,到了你关心的部分,你分析过输入输出,也就能更快速的知道哪里错了。所以不能从输入输出角度进行思考是人们没有办法写出高质量程序的一个原因。

而每一块的编码实现我们还是会再分任务,以本问题单个数的转换为例,接口是非常清楚的,就是输入是个整数,输出是个字符串。

但是你实现的过程要分几步。

我要先实现可以被三整除的。再实现可以被5整除的。最后实现可以被3和5整除的。这算是一个驱动的意思。从简单的入手,然后再往复杂的去写。很多人可能会觉得比较无聊。但如果你测试的人足够多,你会发现很多人哪怕是在这样一个无聊的题上,也会把自己坑进去。举个例子我们第3步:可以被3和5整除。当我们实现的时候,我们if里那个表达式模3模5在上还是在下。每次我都会故意写在下面问有没有问题,如下图所示:

image.png

每次都会有人意识不到上面的实现是有问题的。这么简单的题目都会被绕晕,到底要多有自信,才会觉得复杂的需求不会出错呢?所以还是老老实实的给自己加测试防护网吧,不要对自己的头脑过分自信。测试一个很重要的原则,是防止低级错误,而不是恶意欺骗。前半句是对上面这个情况的描述,而后半句则是针对有的同学在做测试的时候,总是在想,万一有人用”黑魔法“欺骗了我的测试怎么办?这个是没办法的,也不该花精力去思考这个问题,毕竟写测试的和写实现的都是你,你为什么要这么难为自己?

先确认需求,再实现,需求以测试的形式写出来,然后再去实现,这就是tdd了。如果实现的时候只需要关注其中一种可能性,这样思维负担比较轻。如果你脑力强劲,觉得步子大一点没事,你就步子大一点,我是没有此等自信。有些人问我,TDD时候测试有没有阶段性,测试是否有要分批写?我大概会分三批:

第一批测试只有一个测试,这第一个测试的意义:定义输入输出,确定函数在哪

然后是第2批测试,第2批测试的意义:建立主干框架,把程序的主干走通。

然后再写第三批测试,把各种分支和异常都考虑到。这样写出来的程序就是一个比较健壮的程序。

反过来看,当你时间不够的时候。你要减的测试是谁?肯定是第3批测试。不是整组干掉,而是在这组当中减少量。有很多人会说自己没有时间写测试,或者说测试很浪费时间。直到你打开他的代码的时候,你发现你前两组测试都不存在,就很说不过去了,因为前两组几乎不花什么时间。而且如果做得好的话,还会提高效率,减少时间花费。一个最简单的道理:当我有一天出了bug的时候。我能以多快的速度建立一个可运行的程序现场,以多短的周期反复重现这个bug,并且进行新解决方案的尝试,决定了修bug的速度。而前两组测试完全可以为这个场景服务。而这个场景不完全发生在测试测出来bug,在我们日常写代码的时候,我们不能保证我们写的代码是一次就能写对的,那么在没有写对之前就等于代码中存在了bug,也是要反复调试的,那这个对实验的周期时间的要求是一样的。有那个调试的功夫,直接看测试不是一样吗?

过度设计

到此为止,我们写出来带的代码如下所示:

image.png

实现并不复杂,仔细看看这代码还可以,够用,不难懂,那就行了,我们就先不请重构登场了。天下设计都讲究一个不要过度设计,软件设计也不例外,做到这里是很好懂的,那我们也不要画蛇添足。

很多人一看到可能的扩展点,就想了一大堆可能的需求,再有个9呢?或者是所有的素数,比如11啊,13啊……

这方面我们要有耐心一点,比起可能降临的扩展给我们带来的困扰,我们自己乱添加的扩展机制可能会坑死自己的概率也不低。

有个段子是这么讲的,有个人请来了,一个新手,一个老手,一个高手,给他们布置了一个任务,看过一片农田到对面的房子去。这片农田就隐喻我们的代码。问要多长时间?新手看了一眼距离说估计15分钟就能过去。老手看了一眼,说要半天。高手也看了一眼,说15分钟。新手进到农田,不停的掉到坑里,踩爆了几个雷,最后被埋在田里了。老手小心翼翼,过程中填了几个坑,排了几个雷,花了半天的时间,终于到达了房子。发现高手早就已经在那儿等了他很久了。老手不解,问为什么你可以这么快?你怎么干掉那些雷的?高手说,因为从一开始我就没有埋雷。

这段子告诉我们程序员自己给自己埋的雷往往会成为未来的负担,好的程序员会尽量少的给自己埋雷。这所谓的雷,可能一开始就是一个精心设计的机制。

不要以为这只是一个段子,我曾经工作的一个项目上,我接了一个特别简单的任务,我也误以为大概一会儿就能做完,打开代码之后,我发现之前的代码竟然是用反射机制设计了一个极其复杂的扩展机制。最后为了搞懂这个机制,我竟然花了一个礼拜。最后我觉得这个机制实在不利于扩展,我现在对他知根知底,为了防止后人再进这个坑,我就把它删掉了。删之前我就很好奇,这么复杂的机制,有没有起到易于扩展的作用啊,于是我就打开版本控制的历史记录,我发现他是两年前添加的,在过去的两年之中,从来没有进行过一次扩展,直到今天被我删掉。想想也对,这么复杂的代码,别人读都读不懂,为什么会选择在这儿扩展呢?所以不要盲目追求易于扩展的设计,绝大多数时候刚刚好的设计才是最好的设计。

迭代2

前面的做完,新的需求来了:

你是一名体育老师,在某次课距离下课还有五分钟时,你决定搞一个游戏。此时有200名学生在上课。游戏的规则是:

  1. 让所有学生拍成一队,然后按顺序报数。
  2. 学生报数时,如果所报数字是3的倍数,那么不能说该数字,而要说Fizz;如果所报数字是5的倍数,那么要说Buzz;如果所报数字是第7的倍数,那么要说Whizz。
  3. 学生报数时,如果所报数字同时是两个特殊数的倍数情况下,也要特殊处理,比如3和5的倍数,那么不能说该数字,而是要说FizzBuzz, 以此类推。如果同时是三个特殊数的倍数,那么要说FizzBuzzWhizz。

迭代2的需求改动不多,这个需求对我们的业务域造成的变化是加入了一个新的特殊数字7。如果我们还是按照迭代一的方式去实现,我们写出来的代码可能很可能如下所示。

image.png

代码有什么问题吗?它最大的问题叫做圈复杂度太高。

圈复杂度

圈复杂度的计算方式是这样的,每当你看到一个for或者if或者while,总之,每看到一个判断分支或者是一个循环分支,圈复杂度就加1。每当看到continue和break也加1(有的版本是这么认为的,有的不这么认为),return也加1。加完了就是这一部分代码的圈复杂度了。

圈复杂度高了会怎么样呢?圈复杂度高低与bug率高低有强相关性。在各种测试指标当中,很少有像圈复杂度这样与千行代码bug率强相关的指标,相关度高达百分之九十几。也就是每千行代码,圈复杂度越高BUG率就越高。虽然不是因果性,但是对于一个工程学科来说,相关性也有足够多的指导意义了。所以当我们看到圈复杂度比较高的代码的时候,我们就要考虑重构掉。

重构与十六字箴言

这回我们就真的需要重构了,那具体重构要怎么做呢?难道是把这块删了重写吗?那就有点糙了。但精细的讲,重构是有60多种手法的,也没谁都能记住啊。不过好在总的来说手法的模式是很相近的,我们有个同事总结了四句话,我们戏称为“16字箴言”,内容如下:

旧的不变
新的创建
一步切换
旧的再见

什么意思呢?首先不要着急改掉旧的代码,先让旧的保持不变,不过因为intelliJ这种利器的存在,使得抽取函数本身不再是一件危险的事(起码在Java里是这样),所以我们通常会先把要重构的旧的代码抽个函数,让重构的目标显性化。做这一步的时候,你会发现可能已经要改变代码结构了,起码要改造成我前面所说消除过程依赖,让代码之间只有数据依赖,这样才好提取嘛。提取之后写个新实现,然后在调用点调用新实现,旧的调用点先注释掉,测试通过了,在把旧的调用点代码删掉,打扫战场把旧的实现也删掉。

具体到这个题呢,我的做法会是如下:

先消除过程依赖。

image.png

然后抽取函数,把要重构的代码块先通过函数封起来,划定重构的边界,把输入输出浮现出来。

image.png

接着写一个新函数。

image.png

然后把函数调用点换掉。

image.png

然后把旧的函数删掉,打扫现场,该改名改名,该去注释去注释。

image.png

上面的每一步结束的时候都要保证测试是通过的。软件工程当中有一句很重要的理念就是:一个问题发现的越晚修正它的成本就越高。本着这个思想,我们重构的时候也要是这个样子,每做一次修改都要看一看有没有问题,如果有问题就立刻修正。如此小步前进,才是我们所谓的重构。

整个过程,不说每一步都提交吧,一步切换或起码旧的再见之前也要提交一下。因为一旦你重构掉之后,有一天你想看看原始实现是什么样子,或者干脆你就想切换回原始实现。这个时候这一步切换可以最大限度的保留当时的代码的全景,让你很容易的看清当时的实现,也让你的回滚也会变得容易。毕竟谁也不敢保证自己能一次性做对,改着改着发现低估了这个复杂性,还不如以前的设计方便,也是常有的事儿。

迭代3

你是一名体育老师,在某次课距离下课还有五分钟时,你决定搞一个游戏。此时有200名学生在上课。游戏的规则是:

  1. 让所有学生拍成一队,然后按顺序报数。
  2. 学生报数时,如果所报数字是3的倍数,那么不能说该数字,而要说Fizz;如果所报数字是5的倍数,那么要说Buzz;如果所报数字是第7的倍数,那么要说Whizz。
  3. 学生报数时,如果所报数字同时是两个特殊数的倍数情况下,也要特殊处理,比如3和5的倍数,那么不能说该数字,而是要说FizzBuzz, 以此类推。如果同时是三个特殊数的倍数,那么要说FizzBuzzWhizz。
  4. 学生报数时,如果所报数字包含了3,那么也不能说该数字,而是要说相应的单词,比如要报13的同学应该说Fizz。
  5. 如果数字中包含了3,那么忽略规则2和规则3,比如要报35的同学只报Fizz,不报BuzzWhizz。

到这个版本,就是我们拿去做面试题的版本了,揭晓前面吃惊的地方,明明没有说包含5和包含7,结果有近1/3的人自己就写了包含5直接返回Buzz,包含7直接返回Whizz。还有一部分人走向脑补的反面,我们权且叫它脑删,比如这个题就是明明写了包含三,他没做,这种情况也都在那1/3里。

同学们,我需要再强调一遍,脑补是不对的。我去很多学校进行过人才的筛选,我发现一个基本的素质就可以筛掉大多数的人,那就是这个人会不会脑补或者脑删。这个比例有多高……恐怕高出你的想象。有一个研究贫困家庭到底会在学习上遇到什么问题的书里讲,贫困家庭的孩子,最大的能力缺失是阅读能力不行,由于在小时候缺乏了阅读能力的锻炼,绝大多数穷人家的孩子阅读能力都会不好,他们不习惯看长文,阅读也经常会理解错误,于是导致了他们的收入的天花板。

从这几个独立的案例里可以看到一个道理,那就是机器的输入输出很重要,人的输入输出同样重要,有的时候很多人并不是比别人笨,只是因为输入出了问题,智商的部分根本没有机会参与竞争。对于这些没过的同学,他们的问题就出在了题没有读清楚。也就是我们前面讲的业务域没搞清楚,所以最后结果不好。

我们当然也可以说,是你需求没有讲清楚,里面的重点没有标记清楚,没有用一种更容易理解的方式来呈现。是现实中真的存在完全清楚的需求描述吗?由于人类的语言存在强烈的上下文相关性,同一个词语换了上下文都会有不同的含义,换句话说,两个上下文不同的人听到同一个词的理解也是不一样的。我们的工作就是在这样的一种场景下,澄清需求本就是程序员的工作之一,因此沟通表达能力也是程序员需要去关注的能力之一。不然的话就会发生业务域的问题没搞清楚然后试图通过方案域和实现域办法来解决不属于这两个问题域的问题,这是很多难题得不到解决的关键所在,也是很多乱相的根因,具体比例咱不好说,读者可以自行对号入座。

测试景深

到了这个版本的需求里,我们需要处理包含3,这里面有个测试就不太好了,那是什么呢?

输入3得到Fizz这个,如下图所示:

image.png

这个测试我们测试了什么呢?是测试的被357整除还是测试的包含3?很明显,我们测试的是包含3。

一个场景告诉我们,即便是一个测试用例,从入口进去,从出口出来,也不表示他测的是整个逻辑,是更多的关注了其中的一部分逻辑。

这就好像照相的时候一样。即便整个景色都被我照了下来,但是我的一张照片总有一个聚焦的焦点,这个地方会特别的清楚。测试也是一样。如下图所示:

image.png

image.png

所以我们看待测试用例的时候不能一视同仁。这种虽然是个端到端的测试数据,但实际上只关注部分逻辑的思路,在系统重构的时候有更多的使用场景。等我们有机会讲深入聊重构的时候再聊。(不过出现这种场景的时候,专门针对不同的逻辑部分加单元测试,这也是测试景深的提出者追求的。)

一般等价类

从这个场景下我们也可以发现,如果仅写一个输入的值在测试用例的名字上,我们是不知道这个测试用例在测什么的。

测试代码也是代码,也要追求可读性。

所以比起之前写3或者现在写6。用一个更具有表义性的词来称呼会更好,比如像下面这样:

这种更具有表义性的词,我们称之为一般等价类。我们写测试的时候会发现,测试数据经常是无穷无尽的,难道我无穷无尽的测下去吗?肯定是不行的。但是我还是希望能够测的尽量全一点。我测了哪些东西之后,就可以认为我测的比较全了呢,如何来得到一个性价比较高的测试用例集合呢。这时候我们要做一般等价类的分析,在我们这个题里面大概有下面几个等价类:被3整除,被5整除,被7整除,包含3,包含5,包含7。只要是一类的数据,我们只需要一个数据就算是覆盖了这一类的情况。这一类就叫一般等价类。,所以我们改完后的代码应该是下面这样的:

image.png

执行了之后就能看到这个:

image.png

我们经常讲敏捷是对工作的软件胜过面面俱到的文档。这并不是说我们不写文档,而是说我们的文档也是一种可以工作的软件。就像这个测试一样。我们称之为测试即文档,也叫活的文档。代码同样,也叫代码即文档。所以我们前面讲测试也要追求可读性。实际上测试的可能性比实现代码的可能性可能要求还要高一些。不过通常来讲也是有一些套路可循的。

首先我们看名字,should开头,表示输出,given表示输入,有时候也写个when表示被测函数

image.png

对应的,我们的名字的结构搬到我们的代码上,三段式表达,given部分还是输入,when部分就是被测函数,然后then部分写各种assertion来校验

image.png

然后就是粒度问题,通常一个测试只测一个case,这样一旦报错了,我们就可以立刻知道是哪里的问题,从而减少寻错时间。

迭代4

你是一名体育老师,在某次课距离下课还有五分钟时,你决定搞一个游戏。此时有200名学生在上课。游戏的规则是:

  1. 让所有学生拍成一队,然后按顺序报数。
  2. 学生报数时,如果所报数字是3的倍数,那么不能说该数字,而要说Fizz;如果所报数字是5的倍数,那么要说Buzz;如果所报数字是第7的倍数,那么要说Whizz。
  3. 学生报数时,如果所报数字同时是两个特殊数的倍数情况下,也要特殊处理,比如3和5的倍数,那么不能说该数字,而是要说FizzBuzz, 以此类推。如果同时是三个特殊数的倍数,那么要说FizzBuzzWhizz。
  4. 学生报数时,如果所报数字包含了3,那么也不能说该数字,而是要说相应的单词,比如要报13的同学应该说Fizz。
  5. 如果数字中包含了3,那么忽略规则2和规则3,比如要报35的同学只报Fizz,不报BuzzWhizz。
  6. 如果数字中包含了5,那么忽略规则4和规则5,并且忽略被3整除的判定,比如要报35的同学不报Fizz,报BuzzWhizz。
  7. 如果数字中包含了7,那么忽略规则6中忽略被3整除的判定,并且忽略被5整除的判定,比如要报75的同学只报Fizz,其他case自己补齐。

有很多人看到业务复杂到这个程度,总算该设计个机制了吧。我见过很多人在这个环节把代码写得特别复杂。然而我写的代码非常简单,如下所示:

image.png

这种代码有什么好处呢?就是它的逻辑跟我们需求描述的逻辑几乎一模一样。我没有新增什么额外的机制。这看起来不是很高大上的样子。很多人就想加个设计。我们要明白,所谓的设计就是加入一些约束,使得做一系列事的方便程度高于了做另外一系列事。我们经常性的想约束想的都是代码,但实际上对代码的约束,本质上还是对人的约束。Rails的架构师DHH曾经说过约束是你的朋友,什么意思呢?就是说很多时候你对自己加了约束,那么你做事的效率可能比胡乱做、凭本能做更高。从这个角度出发,我们加约束一定要提高我们的整体效率,所以我给自己加了一个约束,叫做让我们的代码逻辑和业务逻辑最大可能性的保持一致。虽然不是一种代码的设计,但是是一种行为的设计。

我们刻意要求自己按照这样的行为行事,会带来一个非常大的好处,那就是我们的业务逻辑的修改的难度和我们代码的修改难度保持一致。业务上如果改起来逻辑很复杂的话,你跟他多要一点时间来改我们的代码也比较容易要到,反之则不容易要到。如果我们刻意做了一些设计。使得局面变成了,有时候业务上改起来很简单的事情,我们这边特别麻烦,而有时候业务上改起来特别麻烦的事情,我们改起来特别简单。因此他们会觉得我们设计在一定程度上提高了他们的效率吗?不会。他们会觉得你今天心情好,所以你说简单,你哪天心情不好了就跟我说做不了。所以你没有办法形成业务与技术的协同效应,全局上来看,它是降低了效率的。

所以要记得我们前面说过的这个原则:让我们的代码逻辑和业务逻辑最大可能性的保持一致。细节上也要注意,要尽量采用业务名词,业务概念。不要写那种写时候只有我和上帝懂,三个月之后只有上帝懂的代码。我们想象一个场景啊,当项目上来了一个新人,他首先会学习业务知识,完了之后开始维护代码,我们尽量使用业务名词业务概念,对于这个新人来说,他的上手成本是最低的,如果业务逻辑又保持一致,那上手成本就更低了。而新人这个概念不一定是新招了一个人,没有维护过你这块代码的,来维护过你这个代码,三个月后的你,可能都是这个新人。所以善待新人就是善待自己。

也有人觉得到这个程度的 FizzBuzz 扩展需要已经非常迫切了,实际上确实是可以看出有一些扩展的迹象了,但是需要做的事应该是进一步探索需求,先去了解业务,而在不确定会怎么扩展的情况下,只能更多的抽取小原子操作,也就是包含3,被5整除这种函数或表达式,而不能过早的预判他们的组合机制,因为我们不知道谁能跳过谁这事到底会怎么演化,到底策略是在哪切那一刀更合理。

我们这些代码也是测试驱动出来的,下面是我新加的测试:

image.png

这些测试通过之后,由于实现的改变,会导致我们前面的测试用例会有很多的改变。无形中制造了测试的麻烦。这个时候我们可以采用测试替身技术。把3,5,7的部分再摘出来测试,这样你就不需要关心3,5,7部分的输入了。这块我们就先不展开了,后续在另一道题里在深入讲。

不管我们是否使用了测试替身技术。我们可能还是不太放心。我想写一个测试用例很全的测试,也就是所谓的细粒度的测试,于是我就写了一个。

image.png

上面就是我用代码生成的数据,这个时候你会发现测试用例一点都不好准备。测试成本很高。这个其实是正常的。测试代码也是需要花心思去写,花心思去维护的。

但是这里面会延伸出一个问题。我写测试来保证实现的正确性,我拿什么来保证测试的正确性呢?

这事情要分两头看。你写的一种测试代码是一种类库的形式,用于辅助测试的编写。这种代码本身跟一般的程序没什么区别,所以你可以针对他单独测试。在这个题里面,我根据不同的条件生成输入数据,就是一个这样的类库一样的程序。另一种测试代码就是我们平常的测试用例了,这种测试用例,它和实现是互相验证的。所以我们在写这个的时候有一个对自己的约束,那就是写测试的时候绝对不写实现,写实现的时候也绝对不写测试,两者之间有一个非常明确的边界:执行测试。也就是说我如果写测试,执行完了测试之后,确保是我预期的结果,我再去改实现。反之我写了实现,如果执行完测试是我预期的结果,我再考虑去改测试。这样你写测试的时候实现就是你的验证。写实现的时候测试就是你的验证。主要在修改的时候作用更大一些。

最后还有一个问题,我写了这老多的细粒度的测试,是不是我原来的那个测试就可以删掉了?我建议不要,一方面,他们可以当文档存在,另一方面你原来的测试相当于一些主干,而哪些细粒度的测试相当于各种细节分支,当我们未来再引入新的功能的时候,你可以先用主干测试来驱动新功能,然后用细节的测试来进行微调。

写到这里,我用fizzbuzz能讲的道理也算讲完了,还有很多道理没讲到,下次换个题目试试。

絮絮叨叨写了这么多,就是讲一个事,技术是由一万个细节组成的,哪怕一个这么简单的题目,也有如此多的点。之前写过一篇文章叫《什么值得背》,就是说了一个道理:高手的解题思路值得背。我也不敢说自己是什么高手,起码写了许多年代码,也就把自己的写代码的思维展示给大家,希望对有心人有所帮助。

(作者这些年一直在从事这个主题的工作,本文是这些年工作的一个总结。另外作者是一个比较守旧的人,所以软件行业、IT行业、互联网行业、数字行业等等,在作者眼中都是一个意思,起码在能力角度并无区别,所以作者并不认为这里面提到的什么东西只适合与IT行业不适合于互联网等等。)

软件开发人员的培养,先说一下解决这个大问题的方法论,首先要理清楚问题域,从问题出发。那么头脑风暴一下,随便选一个问题作为出发点:我们培养的人是在从事什么?答案当然也很简单,从事软件开发。这个问题本身简单到了没什么意义,把一个复杂的问题抽象为了一个简单到没用的问题,如果我们进一步思考(此处省略不知道多少步),就会发现关键的问题应该是,软件开发到底是一项什么工作?

只有清晰的定义了这项工作,才能很好的定义出从事这项工作的人有哪些工作场景,然后才能定义出为了胜任这些工作场景,他们应该具备哪些能力,定义出这些能力胜任时的表现,然后才能谈得到培养,同时还要考虑通过什么方式能衡量这些能力胜任时的表现和他们实际工作表现的差距,以评估培养的效果,指导培养方式的迭代改进。

以上就是我们的方法论,所以作者才会来从事这项工作,因为我不是不相信任何外行能够很好的定义出这里面的内容的。

那么我们从头开始,回到问题:软件开发到底是一项什么样的工作,这个问题有太多的维度,我们仅从能力的角度开思考这个问题,也就是说如果完不成只可能是能力问题,没有其他问题干扰。从这点切入,这个问题可以等价的看成,抛掉所有角色来看,假设存在一个可以干所有角色的事情的人,并且他的时间是无限的,而且他的智力水平是一个中等偏上的人(考虑到大学毕业生仅占中国总人口的5%左右,这个设定还是偏保守了),那么对他来说,开发一款软件是一个怎样的工作呢?

抛掉知识层面的部分,从能力角度,我定义如下:

  • 把你的业务问题转化为数学问题,让它的规则逻辑自洽,然后用代码实现
  • 如果它的规则过于复杂,人力不能判断逻辑是否自洽,进行抽象,寄希望于抽象之后逻辑自洽
  • 如果抽象之后依然过于复杂,不能判断逻辑自洽;或如果进一步抽象将变成过度抽象,从而无法判断是否可以落地,那就切分。寄希望于在一个问题子域中,解决方案可以逻辑自洽,不同的问题子域的方案之间可以映射以合作解决大的问题域

上述三大假设中,仅有人的智力水平中等偏上这一点,是完全成立的。时间无限肯定不可能,但它并不影响对能力的定义,只是影响工期,最多加入一个对时间管理、优先级选择的能力的要求,而这个能力是通用能力,已有解决方案。一个可以干所有角色的事情的人有限成立,因为从能力角度是可以做到的,在软件开发的早期确实大量存在这样的人,我们曾经称之为软件英雄的人大都如此,即便是今天,这样的人也不是不存在。但是在如今这个时代,却不能以这个为目标来发展人,主要导致这个结果的原因是对效率的追求,有限的时间匹配无限的工作量,势必要把人分成多个角色来工作以追求效率,所以这个方向是绝大多数企业无法接受的。不过这里面有个矛盾点,想要把我们定义的三种工作做好,跨角色的能力也就是按照传统软件英雄的角色去培养能力是不可避免的,否则能力的发展是有天花板的。好在就算你不刻意的发展他们这些能力,这些能力也会随着工作年限的增加自然获得,只是效率不高而已,由于大多数企业还没有精力考虑突破天花板的效率问题,所以这个问题我们按下不表,以后我们再深入聊。从这个角度来讲,上述的工作内容是可以成立的,起码我这么认为。

那这样的工作对人提出了怎样的要求呢?在回答这个问题之前,我们还要对问题域进行进一步的思考,那就是人的发展本身有怎样的限制,同时软件行业本身有什么特点结合上人的限制会进一步加剧这个问题的复杂性?

我人的发展认为主要有三大限制:

  1. 人的易错性。即便是做过很多遍的事情,也不能保证每次都做的不出错。如果每次还稍微有点区别,就更难保证了。
  2. 学习需要很长时间。技术知识也好,业务知识也好,本身都是需要被学习才能掌握,而人的学习速度是很慢的,要花大量的时间。不想机器,只要加载了程序,下一秒就可以工作了,而且绝不出错。
  3. 个体间无法快速复制。一个人学会没啥用,别人不会就是不会,好容易培养出一个老手,再复制他/她又要花费几乎等量的时间。不像机器可以快速复制给很多机器,每台都不会有错误。

那么软件行业本身什么特点又进一步加剧这个复杂性呢?

  1. 软件不遵守差不多原则,做出来的东西必须严格符合逻辑,否则就会出错。而这种严格符合逻辑的产品却是要靠有易错性质的人来构建。
  2. 软件系统的分层掩盖了它的复杂性,导致软件的知识在空间维度上是海量的,对于学习是很大的挑战。只要是涉及到纯机器的部分,人们总会封装的去理解,以为挺简单的,然而创新又总是贯穿很多层,所以上层的很多想法到实现的时候,总是很复杂。这就加剧了不同角色间、管理者与被管理者间,老手与新手间的磨合成本
  3. 软件行业的快速发展,导致所学的知识在时间维度上是快速变化的。据说是受摩尔定律影响,我们软件开发人员的世界基本上是极其不稳定的。每隔一段时间,我们依赖的类库、框架就会升级,我们掌握的知识和技能就会在一定程度上过时,这进一步加剧了学习的挑战。

以上的复杂性叠加上人的不足,使得软件开发人员的培养是一个极其困难的问题。理解了这样一个复杂的背景,我们才好思考,软件开发对于人提出了怎样的要求,以及如何培养人来胜任这样的要求。

to be continued……

【旧文搬家】
我的同事王健最近写了一篇文章。名字叫《从汽车贴膜看专业团队》。看了之后感触良多。特别是现场管理,和全功能团队两点。

我有一个观点,说到专业性,传统行业比我们IT行业要强得多。从这篇文章,我们可以看出来,不管是管理人员的现场管理,还是全功能团队,也就是一线人员的全栈能力,传统行业都比我们要强一些。我相信有人就会有些不服了,不管什么现场管理,全功能团队我们也在做呀。

说的没有错,但是,你在it行业里还真不容易找到这么专业的一个团队。而在传统行业里,这种水平的一个团队现在是越来越常见了。这是为什么呢?按说大家都是人,通常来讲,IT行业的人不是素质还高一点吗?我们这个行业的专业团队,不是应该更常见吗?虽然我们不一定要比你强,但是也不会比你弱得这么明显啊。

这当然一方面是由于我们这个行业的工作比较复杂,不容易做到全栈,但我觉得更重要的是,it行业是属于知识工作,知识工作者的现场是非常的不明显,极难做到现场管理。
我们IT行业的管理者,不管是项目经理,产品经理,还是技术领导者,大家也是基本和团队坐在一起。但是坐在一起,并不意味着,就能够真的在现场。

我们看到在那个贴膜团队里,团队领导只需要看一眼,发现有气泡,就知道质量有问题。也就是说,在传统行业进行现场管理的时候,问题都是非常直观的,非常容易发现。在软件行业想做到一点就难的多,几年前,我记得我的同事熊节也曾经写过一篇文章,文章的核心洞见就是软件开发的现场,在代码里。

这个洞见指导思特沃克工作很多年 ,公司里有很多人提出过类似的观点,于是我们的很多方法就是建筑在这些类似的观点之上。

然而,如果我们想要追求IT工作者开发效率的极限的话,这个洞见还不够极致。经过几年的工作,我发现,代码只是软件开发工作的第二现场,软件开发工作的第一现场,在语言里。
这里说的语言,不是,编程语言,也不是广义的人类语言,比如汉语、英语。指的是我们在从事软件开发工作中所使用的一系列术语,和相关的一系列呈现方式和沟通工具。借用一个技术术语,我们所说的语言是一套仅供软件开发所有相关人员使用的、组合的DSL,DSL全称:Domain specific language,中文名叫做:领域特定语言。

DSL就DSL,还组合的DSL,为什么要说的这么拗口呢?什么叫组合的DSL?我们知道在软件开发的过程当中,需要各种不同的角色参与。每个角色有自己特定的领域,泛泛的讲可以分为三类:我们把产品经理和设计人员所使用的领域特定语言叫做设计语言,把开发和测试使用的语言叫做技术语言,把业务人员、组织管理者使用的语言,叫做业务语言。

所以我们使用的这套,领域特定语言,是把这三类语言组合在一起而形成的一套语言。所以这就意味着我们这套语言非常容易充满歧义,造成每个角色自说自话却难以被发现。

软件工程里的核心观点是一个问题发现的越晚,修正它的成本就越高。比代码更早的是沟通,比沟通更早的就是语言。我们用语言去描述沟通的错误,去描述代码中的错误,我们用什么来描述语言的错误呢?还是语言,这就使得整个工作困难重重,难以达成共识。所以我们更需要非常严肃的对待,软件开发工作的第一现场。

之前一些方法试图建立纯粹的统一语言,所有人都说一套语言,这个方法已经被行业事实上放弃了,我们要承认,各自不同的语言有些部分可以简单统一成一种表达以消除歧义,有些部分只能结合。也就是说相关人员要懂多门语言这个现实是我们必须接受的,软件正在吞噬世界,语言只会越来越复杂。就像我们再努力消除污染,也不能幻想世界回到工业文明以前了。就算我们再努力的去建立统一语言,也不可能是一门简单的语言,只能是多门DSL的一个杂合体。

不过由于历史的原因,在行业放弃的过程中,由于反对预先设计走的过了头,不谈建模,不谈标准化成了一种奇怪的政治正确,导致很多优秀的工具和方法被边缘化了。其实我们憎恨的只是预先设计造成的反馈速度变慢。连带着憎恨预先设计时代的一些工具,就有点上纲上线了。

幸而最近几年,各种领域建模的设计方法又重新回归。最近大行其道的,领域驱动设计,就是在很严肃的,对待业务语言的设计和使用。而在前端领域,Design System试图解决前端开发和设计师之间的语言分歧问题。我个人在从事软件开发工程师的培养方面发现,很多传统的可视化工具,比如说UML。如果不以繁重的预先设计为目的,来使用这些工具,仅把它们用作提高沟通效率的工具,他们的威力是十分惊人的。

以我本人的团队为例,我们使用ant design为基础,设计了我们的design system。使得我们可以在三天之内得到一个可以点击的软件原型,并在此基础上,进行各利益相关方之间的需求交流和反馈。在交流的过程当中,我们也刻意的统一了我们的语言。使得我们尽管是一个远程团队,但是当我们在交流的时候,很清楚的知道我们在对信息架构在哪一层进行反馈。这不但使得业务方可以反馈技术方,其实技术方也在引导业务方。语言的影响是双向的。

在技术领域里,我们也选择了隔离性更好的技术架构,使得我们MVP的代码不会变成我们演进道路上必须长期背负的负累。而之所以在一篇聊“语言”的文章里提技术架构,是因为我们认为,真正的架构不是纸上的,却也不是代码里的,而是每个团队成员心里的架构。实施一个架构必然也是要进行大量沟通,也需要统一语言。

而在交流业务的时候,我们刻意的划分了各种不同的子领域又在每个领域当中统一了名词。统一名词还是比较简单的,最难的是划分领域,我们为此投入了大量的工作,也犯了一些错误,但这些付出是值得的,这之后,我们的沟通变得非常流畅。

具体的实践有机会再跟大家分享。我在这里仅仅聊一下沟通顺畅带来的价值。沟通顺畅的威力并不仅仅表现在沟通的时候可以很顺畅的传递信息,最重要的是当有团队成员对信息理解出现错误的时候,可以很容易的暴露和给予反馈。

这个优势在IT行业至关重要。IT行业的人员流动率接近25%,这意味着每年我们团队中至少有1/4的人是新人。而即便我们想尽方法让我们的团队保持稳定,随着敏捷和精益创业的相关思想已经慢慢成为我们的工作常识,每个项目存在的时间都不会太长,从而使得IT团队是经常性的重组,有时是团队被打散,有时是同一个系统从一个团队交给了另一个团队。如果缺乏一种有效的反馈机制,那么无论是人员流动还是组织重组,所造成的切换成本都是一个可观的数字。尽管这个切换成本是无法消除的,但是尽量减少切换成本是我们每个专业人员应该追求的,尤其是团队中的技术领导者。

技术领导者重音在领导,而不在技术。尤其在Tech@Core的今天,技术就是业务。优秀的技术领导者更不能把自己变成一个救火队员,只是被动的响应,尽管救火队员往往很容易被人所看到而获得一些关注和赞扬,但在我们中国的文化里,我们都知道还有更高一层的境界,这个境界存在于很多典故中,比如上医治未病,善战者无赫赫之功。同理,我们软件开发领域的技术领导者们也应该努力使大多数问题发生的基础消灭于无形,这就需要我们走出我们的舒适区,深入到软件开发的第一现场,进行现场管理才能达成。

标题耸动吗?可能你会奇怪,我们不都是人吗?什么叫像人一样工作?

这个问题啊,你还别不认,我们不像人一样工作已经有个把世纪了。这一切都是从工业时代开始的。

工业时代带来的一个问题就是劳动异化,劳动异化说的是,资本家从劳动者手里买走了劳动力,从而使得劳动者的劳动性质产生了某种变化,这种变化叫做被异化。异化会产生什么问题呢?问题在于劳动力被买走了,劳动的意义也被买走了,所以劳动者在劳动中除了定时领工资没有任何意义,在生活中才有意义。没有意义感之后,人就不是人了,所以劳动者在劳动的时候不是人,是个机器,参考摩登时代:

image.gif

资本家非常清楚这个道理,比如福特就曾经有句名言:我就想雇两只手,怎么来了个人呢?

这个问题一直延续到了现在,对程序员也是一样的。不然也不会有前一阵的996.ICU运动,这就是数字时代的工人运动。马云说,他自己超时工作如何如何,这个话说的就没文化,您是资本家,您劳动充满了意义感,而其他人是被异化的劳动者,劳动过程中没有意义感,它能一样吗?

我之前有几篇文章,讲了像机器一样思考的方式来思考软件和任务。而我们毕竟不是机器,理解机器并不是为了变成机器,何况我们也变不成机器。所以我们可以像机器一样思考,最终还是要像人一样工作。机器和人的差别在哪呢?回想一下当柯洁和李世石代表人类坐在机器面前,看着绝对不会犯错的机器,说出绝望的言论时,有没有让你觉察到我们跟机器的本质区别是我们会犯错这个关键约束条件?有些人可能不喜欢这个本质区别,但我觉得这没什么不好,这就好像苹果和梨,没有好坏之分,只是不同,工业时代把人变成机器,数字时代让人重新做人这是个好事。(尽管转变的过程中会有阵痛,但最终的结果也只能是这样,我坚信着。)

既然我们是人,我们接受这个前提,我们就要采用人工作的方式,而不是机器的工作方式。

机器工作的方式是什么样的呢?我们来看个动画:

https://www.bilibili.com/video/av51228411

这个问题在哪呢?在敏捷社区里有一副很有名的画,可以很好的说明人和机器的区别:

image.png

看这个过程像不像前面机器公敌里的机器人作画的场面?

如果我们按照像机器一样思考教的里面画好了任务后,按照上图的方式去实现,就叫像机器一样工作,实际上这个行业里大量的项目都是这么干的,真是悲哀。每个人领一个模块,最后拼成一个功能,绝大多数人都不知道端到端什么样给客户提供了价值没有。这种工业时代的做事方法最大的问题就是把人当成了机器,像上面那组图一样工作,然而人做事是从粗略到丰富的画面一点点变过来的,就像下面这张图:

image.png

图片来源: https://acejoy.com/2018/04/20/438/

可以看到第一张图里,每一步都做得很完美,但是不到最后一步,它不是一个完成的成品。观察一下打印机,就是这么工作的,哪怕过程中出了状况,中断了工作,已经完成的部分每一个细节都是完美的。

而我们人是会犯错的,并不能做到像第一张图那样工作,反而是会采用第二张图那种方式。除了避免犯错,还有一个核心差异促使我们这么这么干,那就是机器是不知道疲劳和厌倦的,而人会,所以人在一个漫长的造物过程中,需要一种东西帮助自己持续获得前进的动力,那就是文章开头所说的意义感。

意义感是个很个人的事情,每个人对意义的定义不同。但工作中的意义感又确实有一种模式化的方式获得,那就是创造闭环。尽管人和人有很多的差异,但是只要完成一个闭环,大多数人都会产生或多或少的意义感。第二张图里,每一步都是一个完成了闭环的输出物,第一张是草图,第二张是简单涂色的图,第三张是完成稿。每一步我们都觉得完成了些什么,每一步我们都会有一点意义感。

作为一个人,我们需要这种东西,所以我们创造出了很多按照这种方式工作的方法,所谓的敏捷、所谓的迭代、所谓的冲刺、所谓的PDCA、所谓的TDD等等等等。一切的一切都在创造这种闭环,然后缩小闭环周期。而与之相反的各种工作方法却是在割裂闭环或拉长闭环周期,让大多数人都活在一段缺乏意义感的流程中或像鸵鸟把头埋到地里一样获得一种虚幻的安全感。天下方法千千万,这两种区别是本质区别,我们推广敏捷本质上是在追求前者的普及,与后者对抗,这是两种价值观、两种立场、两种社会算法的的对抗。

当我们用TDD的方式写代码的时候,当我们用PDCA一点点的精进我们的匠艺的时候,当我们每次冲刺的交付物都得到用户反馈的时候,当我们努力缩小反馈环追求更小闭环的时候,不管外面是不是有一个邪恶的资本主义体系在控制着我们娱乐至死,我们真实的感受到了自己像人一样工作着,感受到了劳动的意义,那这就是值得去做的。

那么这个值得去做的事情由谁来让它成为现实呢?我想来想去,只有团队的Leader,作为团队的领导者,你要去思考到底要求大家采用什么实践去做事的时候,除了想一想效率之外,也要想一想怎样的做法会让大家感受到意义,感受到像人一样在工作。在效率与意义之间的平衡,这是每个领导者的社会责任。