PPT
演讲文稿
开场
我们本次分享的主题是“设计原则与单元测试”。这两个概念对于每个开发人员都耳熟能详,但有多少同学可以在实际开发中真正落地并有效提高研发质量呢?代码的设计原则关注点在开发阶段,单元测试关注点在测试阶段,这两者又有什么联系呢?本次分享我将和大家一起探索其中的奥秘。
很多关于设计类问题都没有标准答案,就像审美一样每个人有自己的偏好。本次分享内容包含了很多我的个人观点,并不一定正确,分享过程中大家可以随时讨论,我们本着求同存异的原则来进行学习。
内容
作为一个研发人员,相信大家在技术道路上的成长历程有很多相似的地方,我总结了几个阶段如下:(PPT中每行逐渐出现)
- 新手上路: 开机VC6 ,main里闭眼就梭哈。我们在学生阶段接触编程时并没有软件设计的思维,直接在VC6 IDE里面写一个main函数,然后就闭眼一路写到底,大家回想下当年的自己,是不是有内味了?
- 小试牛刀:层层调用深无底,指来指去在哪里。我们在初次接触大型项目的时候,看源码时是非常痛苦的,因为大型项目的结构复杂,函数逻辑调用层次很深,特别是很多C语言项目,很多软件抽象全靠指针实现,看着看着就不知道指针指向的是哪了。
- 略有小成:json model controller,CRUD InternalError。工作了一段时间后,我们适应了工作环境与开发模式,大多数人都在写业务代码,在MVC框架中流程基本就是在相关的Controller里面解析json,创建数据模型,做数据库相关的增删改查操作,出错了也能很快定位代码错误,成为了一个“熟练工”。
- 渐入佳境:算法模式加测试,六点下班看电视。如果你不满足当一个熟练工,那就要掌握更多的技能例如用好的算法优化核心逻辑,选用一些设计模式来重组代码结构,用一些测试手段来提高交付质量,这样就能避免很多没必要的加班,早早下班休息。
- 孤独求败: 就这?。看到任何需求等都能直击本质,抓住要害,此乃高手中的高手。
那么我们为什么需要代码设计呢? 本质还是我们人类大脑处理信息的能力和要处理的信息量相比还是太小,所以我们需要进行一定的设计拆分来简化每个步骤。其实不止软件研发,各行各业都是这样,遇到问题先进行整体计划与设计,拆分步骤,这样的方法论是人类处理所有大型问题的惯用方法。
当然对于研发人员,代码有良好的设计,最终可以让我们的项目bug少,这样加班就少,是不是离幸福生活又近了一步呢?
我们先来看看码农人人都用的面向对象的设计原则,虽然我们从学生阶段就接触OOP,知道OOP的三大特点:继承、封装、多态,但在工作中大家真的都用对了吗?
先说说继承。继承是目前存在争议比较多的OOP概念,因为它有两个痛点:1.父子类是高度耦合的 2.继承层次多了以后,代码可读性就不强了,不能所见即所得。其实我们平时的项目代码中有多少人是为了简单的代码复用而选择继承呢?一些函数用的较多,就提取出一个父类,把这些函数放进去,让需要使用的类去继承这个父类,这是误用继承的典型范例。
我在ppt左下角放了一小段GO语言代码,很多写GO的程序员经常称这种struct的使用方式实现了继承,但对于静态类型语言,继承的一大特点就是父类变量可以引用任何子类对象,但这段代码时编译不通过的,因为这里其实不是继承,而是一种单纯的代码复用手段,在GO里面没有继承,但大多数情况下都没有阻碍大家实现需求。我想通过这个例子告诉大家,我们很多场景下想要的特性可能不是继承,而是一种代码复用手段,这时我们应该用其他方式来实现这种复用(比如组合),而无需使用继承进而引入一些不必要的麻烦。
我们看ppt右侧,也是一种OOP典型问题。 我用python简单写了个类定义,里面定义了一个process方法,而方法体是一个图片,可以看到图片里面的代码杂乱无章,这就是“面条代码”,关于面条代码的概念大家可以自行搜索,还挺有意思的,简单的讲就是像意大利面条一样纷纷扰扰,让看的人毫无头绪。这样看似使用了OOP语法写代码,但其实和OOP一点关系都没有。OOP研究的是对象和对象间的关系,第一步就是要拆分,把逻辑封装进各个对象,但面条代码把所有逻辑揉在一起,虽然都是用class关键字写代码,但完全不是一回事。
刚才提到OOP中一些错误使用继承的例子,但有的同学就要问了,那到底什么时候该用呢?就像炒股的人听了一堆投资理论后还是不知道到底什么时候买,什么时候卖。
里氏替换原则,就准确告诉了我们到底什么时候该用继承。里氏替换原则简单的说就是在代码中,所有父类出现的地方用子类去替换后,程序没有任何异常行为出现,那么父子类的继承关系就是正确的。这个理论作者在1987年一次学术会议上第一次提出,到1994年才正式在论文“A behavior notion of subtyping”中发表。这个原则的强大之处就在于把一件模糊的事情量化了,让开发者有了一个准确的标尺去使用继承。
PPT左侧是一个错误使用继承的例子,子类LazyChicken继承了父类Chicken,他们都有一个crow方法,从名字上看似这个继承关系没有问题,但对于crow方法的行为,父类Chicken不会抛出异常,LazyChicken只会在9点以后调用crow是正常的,否则会抛出异常,这对于两个类的调用者来说逻辑处理就会有不一致,所以打破了里氏替换原则,这里的继承其实是不合理的。这里改进的方法有很多,比如修改LazyChicken的crow,不要抛出异常,改为利用返回值等方法使父子类的行为一致。
大家看到这个标题可能会感到奇怪,我为什么为称这些设计原则“假大空”,其实这些原则不假也不空,但是给我们的感觉就是虚无缥缈,没有很好的方法去落地。这几个原则相信大家都听过,SOLID原则比较有名,我叫它“五兄弟原则”,因为我在实践中发现自己的代码要不基本都满足这几个原则,要么就都不满足,他们好像要么一起来,要么一起走,就像几个兄弟一样亲密无间。后边我们会聚焦SOLID中两个原则:接口隔离原则、依赖倒置原则。详细讲解,这里先简单介绍下其他几个原则。
单一职责原则。一个类或者模块应该只负责一个功能,和我们平时所说的高内聚一样。
开闭原则。软件的组成部分应该对修改关闭,对扩展开放。这里简单的说就是增加新功能,之前老的代码不需要大量修改,只需要增加新的函数或方法就可以了,新功能添加后之前写的单元测试都还可以运行通过。
里氏替换原则。这个我们刚才在OOP里面提到了,这里不再赘述。
KISS原则。保持代码简单。
DRY原则。不要重复我们的代码,提高代码复用性。
KISS和DRY是不是感觉更加的“假大空”,也很难找到一个标尺去衡量这他们。
在SOLID中的接口隔离原则可以很好指导我们做接口设计。这个原则的内容是:客户端不应该被迫依赖那些它用不到的接口。
我们可以先理解这里的接口是我们编程语言中的interface。这里我放了一段GO语言代码,早期版本的GO语言要实现对一个切片数据结构内容做排序,这个切片必须先起一个别名类型,然后这个新类型必须实现Len、Swap、Less三个方法来满足sort.Sort的参数要求的接口,这就是一个打破接口隔离原则的典型例子。一个Slice类型,长度和交换方式都是明确的,明明只需要提供一个Less方法来告诉排序算法如何比大小就可以了,但又要多实现两个方法,是不是很傻?后来GO语言更新了相关库,提供了sort.Slice来解决这个问题。
在GO语言中,标准库提供的很多接口都是很小的,只有一个方法,比如io.Write, io.Read。当需要更大的接口时使用这些小接口相互组合,这样更加灵活,避免出现刚才那个接口太大的例子。
注意,我们这里的接口一定要从使用者的角度去判断,而且不是提供方。使用者用起来“难受”,那这个接口大概率就是有问题的。
我们跳出编程语言的圈子,这个原则所说的“接口”其实可以是很多东西,比如提供的rest接口返回的http body内容。我们在工作中经常会碰到调用别人的API返回大量的数据,但我们其实只需要其中的一两个字段,但为了得到那一两个字段,要费尽力气解析很多外围的数据,这其实也违反了接口隔离原则。大家可以拓展下思维,把这里的“接口”引申到更多不同模块“接洽点”,可能会发现很多之前没有注意到的问题。
SOLID中依赖倒置原则是我认为是最重要的原则。因为遵循这个原则,我们可以有效解耦代码,这一点很重要,我们后边要讲的单元测试就和它有很大的关系。
依赖倒置原则,就是在调用关系上高层次模块不应该依赖于低层次模块的实现,他们都应该依赖于接口,而接口又不应该依赖于具体的实现细节,实现细节应该依赖于接口。
这里的官方描述可能比较绕,所以我把这个定义用自己的理解拆成了两部分:1.引入一个中间层,就是接口,我们要面向接口编程 2.接口定义时我们的视角是站在高层模块的需求上去定义。 做到这两点其实就达到了依赖倒置原则的要求。
依赖倒置原则本质是为了让我们解耦代码,平时我们大部分人实现需求可能就是在一个方法里面直接new一个逻辑类来调用其中的方法,当这个逻辑类做一些改动时,可能会直接影响上层的调用逻辑。但如果我们站在调用方调度设计好想要的功能接口,再让底层逻辑类去实现这个接口,这样底层逻辑类做一些变动,对上层是透明的。因为现在二者都是依赖于这个接口的,接口不变,整体就稳!这个定义接口的过程,也是让我们好好思考功能设计的过程。
大家可能在这里会提到依赖注入,其实依赖注入相比依赖倒置原则算是一种落地手段,是类似术与道的概念。在静态语言中,我们按依赖倒置原则抽象接口后,这个接口放在类的构造函数参数上,把依赖的实现了这个接口的类从外部传进来,这应该算是一种最佳。
我们看两个比较典型的例子。
Spring是Java社区中最有名的框架,现在基本处于垄断的地位。右侧的代码中bookService是个接口,这个对象其实使用注解标记后由框架自动注入进一个实现了bookService的对象,在这个代码片段里面就是BookServerImpl的对象。
Angular JS是前端领域目前比较先进的框架,主要语言是TypeScript,和之前Spring的代码结构类似,也是通过注解标记的方式,cartService对象可以由框架直接注入进来。
这两大领域目前最优秀的框架其实都在引导用户遵循依赖倒置原则来设计代码,用于使用这些框架写出来的代码天然就是解耦的。
依赖倒置原则实现了代码模块间的高度解耦,又带来了另一个非常重要的副产品:可测性
这里我为什么说单元测试是通向良好代码设计的“笨方法”呢?因为前面讲了那些原则,对于新手还是比较抽象的,对着代码编辑器空想写出来的代码还是很难满足那些原则,我在实践中发现,单元测试其实可以倒逼我们对代码进行良好的设计。
在讲单测与代码设计的关系之前,我们准确理解下单元测试的概念。理解其与集成测试的区别(按ppt讲)。
单元测试有很多特点,其中最重要的是FICC,Fast、Isolated、Configuration-free、Consistent
工作中我碰到很多同事把单元测试写成了集成测试,比如执行单元测试前要连接真实的数据库等,还有因为代码中有很多全局变量,导致单元测试代码会改变整体程序的状态,单测必须按一定诡异的顺序运行才能成功。为了达到公司要求的测试覆盖率,要精心设计测试代码才可以。
在写单元测试前,我们必须先正确理解单元测试,避免发生上述提到的问题,我们才能进行下一步。
单元测试大家虽然都知道,但落地很难,我总结了以下问题和对应的解决方案:
(这里按ppt上的讲)
(为了趣味性左上角6行渐出效果)
工作中为什么大家都喜欢写集成测试,是因为容易写,那么单测不好写是因为被测代码中耦合了很多对象我们无法控制。其实归根结底是我们的代码设计有问题,导致没办法很轻松写单测,一早就被拌住了脚。
我这里列举了一段python代码的例子,Shopping类中的doShopping方法其实是不可测的,因为其依赖的两个对象shelf、logistics在__init__方法中直接写死了,我们无法控制其行为。左侧我重构了Shopping类,用依赖注入的手法来将shelf、logistics两个对象从外部传进来(这里可以理解shelf、logistics本身也是一个接口,动态类型语言的ducktype),这样我们就可以mock这两个对象进而测试doShopping方法。
我们利用依赖倒置原则和依赖注入的手段,进行了一次重构,单元测试很容易就写出来了,这就是可测设计。
前述中我们提到可测设计的概念,为了更容易编写单元测试,我们需要针对可测性进行额外代码设计。前述例子虽然很多是python代码,但我是以静态语言的视角去看待的,没有利用其动态特性。但在动态语言的世界中,程序的大部分状态都是可被动态改变的。
我们先看下这段python代码,和上一页ppt中代码结构基本一致,Shopping的__init__方法中虽然直接耦合创建了Shelf、Logistics对象,但testcase中我们可以用setattr这个python提供的函数来动态替换shopping对象中的shelf、logistics, 右侧的testcase使用importlib和setattr动态替换warehouse模块中的方法。
从这个例子中我们可以看到在动态语言中如果为了单元测试,那么可测设计好像显得没那么必要了。是的,可测设计并不是代码可测性的必要条件。
前边我提到“单元测试是通向良好代码设计的笨方法”,也就是说,可测设计带给我们的不只是代码可测性,因为设计是一项单独的活动,这项活动有着各种各样的结果,而不只是产生关于可测性的代码重构。在进行可测设计时,我们要思考如何解耦代码,在分离逻辑,拆分模块时,我们要遵循面向对象的原则,高内聚低耦合的原则,接口隔离原则,依赖倒置原则等等... 在这个过程中我们其实让代码结构更合理,更易维护了,而不只是得到了可测性这一个结果。
下面我们来讲点哲学的东西。大家看到这个标题可能会感到奇怪:代码设计和牛顿第三定律有什么关系? 这张图的来源是《星际穿越》这部电影,主人公在驾驶飞船利用引力弹弓效应从黑洞边缘逃逸,在利用黑洞引力完成加速阶段后,逃逸过程会受到黑洞的反向引力,为了减小飞船质量从而让反向加速度减小,主人公让自己所在的附属仓从飞船脱离而掉入黑洞。这里的剧本把牛顿定律讲出了哲学的味道,想要达到一定的目的,必须付出相应的代价。
我们做代码设计当然也符合这样的哲学规律。我们可以思考下可测设计带来了什么。第一点当然是可测性,其次是可扩展性,然后我们的代码应该大概率是符合SOLID原则的,因为刚才提过我们的设计是一项单独的活动。
有得必有失,可测设计又牺牲了什么?
第一,工作量,因为我们要做额外的设计工作,这当然是要消耗额外的脑力和时间的,肯定会增加我们的研发工作量。
第二,复杂度,我们需要额外抽象很多接口,解耦模块时当然对项目整体来说会增加很多模块,这可能会增加一些复杂度,特别是对于还没有相关设计思维的同学去看这些代码时可能会觉得搞这么多抽象没有必要。
第三,在一定的情况下,我们是无法实现代码可测性的。无论是历史原因还是某些特殊的场景,代码都无法改造成可测的,我们可能需要另想办法来保证软件质量。
做任何事情都是有得有失,对于代码设计,我们需要尽可能清楚一种设计模式会带来的问题,代码的可读性、可测性、扩展性、维护性与开发效率不可能兼顾,必须要视现实情况做出取舍,找到平衡点。很多同学可能会疑问,在写代码的过程中,感觉类可以不断拆分下去,抽象可以不断地提取,一不小心就陷入了过度设计的陷阱。这个问题就和新手厨师问一道菜要加多少盐一样,都是没有标准答案的,我们必须不断思考,不断尝试来获取经验,最终会和高级厨师回答的一样:适量!
为了更好推动这些软技能在团队落地,我聚焦项目开发每个阶段做了一些总结,希望能有更整体性、系统性的思考。
设计阶段我们需要充分理解业务,然后来抽象出这个业务领域的模型,模型建立后我们就可以很容易地预测扩展点,代码的可扩展性设计就大大提前了。
编码阶段我强调强制思考。我们可以回忆一下自己初学打字时,按下每个按键前都要思考用某根手指去按一个目标键位,当我们熟练了以后就不需要太多思考了,有了“肌肉记忆”。其实所有事情都是熟能生巧,在我们刚开始做一些思维习惯的转变时比较艰难,等熟悉了,就自然而然成为了高手。
测试阶段我们要编写单元测试,写单测时会发现某些代码不容易测试(前述代码不可测问题),这算是一种兜底方法,为了满足单元测试的要求,我们必须重构部分不合理的代码。
这里我还想额外提一下广大开发人员都比较关心的编程语言问题。从C语言到Rust,从Javascript到typescript, 我们可以发现一个特点就是:编程语言让程序员越来越不自由了, 熟悉Rust的同学可能知道,想让代码编译通过就很不容易,规则细节很多,程序员完全被硬规则框住了, 但满足这种规则后,程序bug更少,更稳定。大家可以思考下为什么会有这种现象,我个人觉得是因为时代变了大人。早期是程序员单打独斗的英雄时代,一两个高手就可以做出牛x产品。而现在是多人合作的现代化工程时代。
例如我们的腾讯云,如此大规模的工程,是需要上千程序员共同协作才能完成的,如此众多的项目成员,很难保证技能水平一致,这种场景下工程化是首要问题,能让大家写出来的代码都差不多,降低阅读他人代码的成本就很重要了。
最后我想提一下代码品味,Code Taste.
这个是开源项目Linux的发起者linus在TED上的一次分享中提到的, 两段C代码实现了同样的功能:从链表中删除一个节点。左侧代码最下方对于特殊情况有if判断,右侧代码则没有特殊情况处理,代码很一致,观赏性强,从linus的观点看,右侧代码“品味更好”。
我个人觉得每个程序员都要有代码品味,不一定像linus这样“苛刻”,代码品味其实是一种对于优秀代码的追求,需要不断思考,阅读高手的代码,反复打磨自己的代码,才能培养出一种较好的代码品味。
最后,希望在场的各位写出来的代码都和诗一样优美!
TED视频连接: https://www.ted.com/talks/linus_torvalds_the_mind_behind_linux/up-next