前言
在我工作的这几年,设计模式这个词就不断萦绕于耳,为了学好设计模式,我曾不断的生搬硬套4人帮的著作(我是一名python程序员),在不断的工程实践当中,越发发现,设计模式的本质并不是前人总结好的套路,他遵循一定的原则。也就是我们耳熟能详的“SOLID”,一旦你的软件遵循原则,那么他其实也是你自己的模式。可回过头来你会发现,遵循这些原则并不容易,在日常coding当中,我们经常会为了快点实现功能,而把原则抛向脑后。美其名曰:先实现!再优化!随后便陷入了怪圈,实现好了不想动->动了又怕出错->新的需求来了->没时间重构->继续用糟糕的实现的死循环怪圈!
久而久之,代码变不可维护,开始可能是只有自己和上帝知道,最后就只剩下上帝知道你到底做了什么。
那么我们该怎么办呢?有什么办法才能让我们摆脱这个怪圈!?测试先行->单元测试->TDD
设计模式的本质
什么是设计模式?
软件工程中,设计模式是指软件设计问题的推荐方案,设计模式一般涉及如何组织代码和使用最佳实践来解决常见问题,设计模式作为一个高层次的方案,并不关注具体实现细节(面向对象特征)。经常会有人错误的把设计模式或者设计原则当成刻板的教条,却从来不关注软件工程当中从来没有”银弹“。于是就出现了如下几个误解
- 我们应该一开始就想好应该使用什么设计模式。
很多人经常喜欢纠结在代码中应该使用哪种设计模式,他们甚至都还没有尝试一下使用自己的方式解决问题,形成了设计模式教条主义!这种想法不仅仅是错误的,而且违背了设计模式的本质,设计模式本质是在已有的方案上发现更好的方案(不是全新发明啊,别自做聪明)。如果你一个方案都没有,又何谈一个更好的呢?
- 我们无论在什么时候都应该使用设计模式
软件设计当中是没有银弹的,很多时候,我们有些代码根本不需要设计模式!随便一个简单的功能便教条主义的使用设计模式,使得方案夹杂了多余的接口与分层,使其方案变得极其复杂,更违背了使用设计模式的初衷。有些人以为只要我使用了设计模式,我的代码就会变得更加优雅,容易维护。事实上却适得其反,本末倒置。
软件设计的基本原则
老生常谈,SOLID,当我们剥离了设计模式的高层,会发现。设计模式的初衷是对常见问题给出符合SOLID设计原则的推荐方案,同理,既然设计模式由设计原则演化,本质上一样仅仅只是指导和建议。盲目实施设计原则一样会导致我们不能集中主要目标,即产生可工作可维护的代码。具体的SOLID的原则就不在这里细讲了。
我们该怎么办?
再说了上面的东西后,我们不免会陷入焦虑当中。既然设计模式不是那么可靠,设计原则也并非想象当中那么好用。我们该如何生产出优质的代码?经过3年的实践,我终于发现,其实不用想那么多,总结就是一句话:行动起来,实现目标。
行动起来
在我过往的工作当中,起初也不是TDD的忠实粉丝,我更喜欢撸完功能再总结逻辑进行重构,并且我也是一个不折不扣的设计模式教条主义者。在最开始的工作当中,简单的业务逻辑,简单的系统模块非常容易这么做。但随着业务不断扩大,逻辑逐渐复杂。我已经无法控制代码复杂度的上升。之后才意识到TDD的重要性。下面我将介绍一下我实践TDD的一些经验。
第一步:关注目标
当我们开始动手去实现的时候,我们经常回想我们该怎么做?我们该用什么样的数据结构?什么样的算法?什么样的设计方案?怎么做才能更加优雅?其实大可不必,我们只需要关注目标产出,并提出三个假设进行思考
- WHAT,做什么?
我们做的是个什么东西,这个东西抽象出来到底是个什么?从此发散出大概的一个逻辑。
- WHO,给谁做?
我们这个东西给谁做的?是给其他合作项目组做的?是对模块的?还是给用户做的?从而发散出大概边界条件和输入输出。
- WHY,为什么要做?
我们为什么要做这个?我们有没有相似的东西可以扩展?从而发散出我们做的这个东西能不能通过其他模块扩展得来。
第二步:测试先行
思考上述三个问题,我们应该能大概了解到做这个东西大概有哪些步骤?哪些边界条件?有哪些输入和输出?我们需要哪些外部系统或者外部模块的依赖?并作出以下预估。
- 外部系统依赖项
当实现一个需求时,往往我们需要大量的外部模块和外部系统例如:数据库,缓存,其他模块和依赖,我们需要知道大概需要哪些东西?能够帮助我们完成目标
- 假定边界条件
我们需求会有输入边界条件?如:不能大于多少的数字?是否需要登陆条件?用户的角色是什么?有哪些权限才能够使用?
- 细化处理步骤,保证测试用例简单且快速
这个时候我们就要动手写测试用例了,这个时候不要着急如何编写单元测试,如写好依赖的数据库,缓存。就只需简简单单的写出假设的边界条件和部分测试函数
test_xxx1(test):
test.data = "22222"
test_func(test.data) :
assert xxx === out
def test_xxx2(test):
test.data = "233333"
test_func(test.data)
assert xxx === out
第三步:开始编码
《黑客与画家》当中,作者告诉我们,写代码就像画画一样,不是一下子就画出来了。而是,我们用铅笔画出结构,然后拿橡皮擦擦改改,然后上色,画出阴影,然后再擦掉重新修改。那么我们编辑器就是我们的铅笔,而我们的测试用例就是我们的橡皮。此时我们更多做的是持续修改测试用例和我们的代码。不断地拓展边界条件和依赖。直到代码按照我们预估的进行。如此反复带着反馈工作。
解除依赖,完善设计
如果按照TDD工作的话,你会发现,你的代码基本上遵循了单一原则(S),因为一个好的代码首先是易于测试的,单元测试也是一个函数一个函数测试的。但这时你会发现一个问题,就是依赖,因为此时代码肯定会有许多强依赖。如我们查询数据库,其他模块儿的配置项。此时你应该考虑你的代码是不是违反了依赖倒置原则。即所谓高层模块不应该依赖于底层模块,两者都应该依赖抽象。我用伪代码稍作示例
如:我们的代码
def login(request):
balab = db.sql("select * from User")
在上述代码中,我们依赖了底层模块的数据库,这就导致了我们测试用例当中,无法测试,因为我们必须要保证环境当中有数据库,而单元测试最好是不要依赖外部系统,这就导致了我们的代码不能易于测试,因为你违反了依赖倒置原则,我们稍作修改
class User:
def __init__(self, db):
self.db = db
def get_user(self):
self.db.query(xxxxx)
def login(request):
user = User.get_user()
此时我们就能把依赖清除掉,做成mock,让get_user直接返回我们的假数据进行测试,如果我们还要对db.query做测试,我们可以对self.db依然是抽象,我们把db也mock掉,输出我们假的数据。方便我们做测试
恼人的全局变量
无法更好的将全局变量引入到测试夹具当中,上述方法并不能解决我们的问题,这该怎么办?
解决方法其实很简单,抽象出全局对象,子类化全局对象伪造。以便放入夹具当中
完善工作流程
坚持TDD的好处是明显的,你会发现不知不觉当中你已经完善了代码,并且保证了代码质量。且每一步骤都有迹可循,避免了环境问题难以测试,完善的工作流程会让你更加专注与工作,而不是其奇怪该的问题。此时你的工作流会是这样的
结语
TDD的好处是显而易见的,但是不代表说单元测试就一定是OK的,我们仍需要大量的集成测试来完善测试用例。测试用例只是橡皮而不是指导,更不能摆脱基准测试的作用。他只是你的好朋友,帮助你快速反馈代码的工具,他只是你的橡皮擦。
参考资料
《修改代码的艺术》
《单元测试的艺术》
《精通Python设计模式》
《测试驱动开发的艺术》