前言
在计算机世界摸爬滚打多年以后,前辈们发现软件系统总是在不断迭代中,产生了坏味道,让后续的系统变更举步维艰。为了应对呢,他们总结出了几条软件设计的经验,后来被称作SOLID原则:
- Single Responsibility Principle(单一职责原则)
- Open Closed Principle(开闭原则)
- Liskov Substitution Principle(里氏替换原则)
- Interface Segregation Principle(接口隔离原则)
- Dependency Inversion Principle(依赖倒置原则)
单一职责原则
定义:对于一个类,应该仅有一个引起它变化的原因
这个意思其实大家都很好理解,无非就是一个系统/一个文件/一个类/一个函数只做一件事情,也很乐意遵守,但真正做下来可能不太理想。难点在于:1. 职责的粒度,一个类该管多少逻辑,什么时候该拆分了;2. 职责的划分,这个功能到底应该由哪个类实现。
我看过一些不错的相关文章
大体的意思也是:职责划分是重点、难点、易错点。今天也想分享一下自己的理解。
由例子引出
假如我们是某手游开发厂商的程序员,这里有一个某手游的用户类:
版本1
{
用户
+ 昵称
+ 等级
+ 当前经验值
+ 年龄
+ 国家
+ 时区
+ 昵称是否合法()
+ 是否可升级()
}
我将所有字段信息都放同一个用户类,你觉得有点别扭,但又好像还能接受,这不都是和用户相关的功能吗?有什么问题呢?
再看一下,昵称是否合法()和是否可升级()都在同一个类中,前者仅仅作用于用户基本信息,后者具备了游戏的属性,修改其中一个功能,可能会影响到另外一个。我们试着按这个思路给修改一下:
版本2
{
用户
+ 用户基本信息
+ 用户游戏信息
}
{
用户基本信息
+ 昵称
+ 年龄
+ 国家
+ 时区
+ 昵称是否合法()
}
{
用户游戏信息
+ 等级
+ 当前经验值
+ 是否可升级()
}
看起来舒服多了,字段和方案都按”基本信息“和”游戏信息“归类了,那么效果如何呢,我们来推演一番,假设又来新需求:
需求A:游戏被王女士举报,耽误了孩子的学习。你们被要求:未成年人只允许在节假日玩2个小时。
那么只需要在用户基本信息类中,增加是否已成年()方法供外部调用即可,其余类不需要修改。判断是否能进入游戏的功能实现由别的团队负责,略(其实就是懒得写)。
版本3
{
用户基本信息
+ 昵称
+ 年龄
+ 国家
+ 时区
+ 昵称是否合法()
+ 是否已成年()
}
你们团队以这个设计,运行了相当长的一段时间,效果还是不错的。这时候又来一个新需求:
需求B:公司准备筹划周年庆活动,用户可以在活动期间参与抽奖,奖品将以邮寄方式发放,但部分地区不支持配送。
为此就需要收集用户的地址信息,地址嘛,属于基本信息,这不就妥了吗:
版本4
{
用户基本信息
+ 昵称
+ 年龄
+ 国家
+ 时区
+ 省
+ 市
+ 区
+ 街道
+ 门牌号
+ 昵称是否合法()
+ 是否已成年()
+ 地址是否合法()
+ 周年庆活动奖品是否支持配送()
}
嘿嘿,你已经嗅到”坏味道“了是不是?还是那个问题,两个功能的后续迭代会相互受影响。再者用户基本信息应当是稳定的,它依赖一个业务规则多变的活动信息可不是一个好的实践。我们可以试着拆分开来,地址不仅有属性,还拥有自身的行为地址是否合法()了,那可以单独抽象成用户地理信息类,为后面单独演进提供基础;而周年庆活动奖品是否支持配送(),属于某个活动奖品的配送策略,则可以抽象一个活动奖品的概念。
版本5
{
用户基本信息
+ 昵称
+ 年龄
+ 用户地理信息
+ 昵称是否合法()
+ 是否已成年()
}
{
用户地理信息
+ 国家
+ 省
+ 市
+ 区
+ 街道
+ 门牌号
+ 时区
+ 地址是否合法()
}
{
活动奖品
+ 所属活动
+ 奖品名称
+ 奖品配送范围
+ 活动奖品是否支持配送(用户地理信息)
}
拥抱变化
我们现在来分析一下,如果在版本2的业务阶段,没有需求B的话,这样的设计一直是“够用”的,看需求A的实现成本可知。就因为来了一个需求B,导致如果不做适当重构/设计的话,系统就有了“坏味道”——版本4。
这告诉我们:同一份代码,在不同的业务场景下,它的单一职责原则的要求会有差异。
上面的例子都是以需求变更来说明,想表达的就是软件的可修改性非常重要,如果你的软件系统难以修改,甚至不可修改,那或许应该称作硬件?过几年就会过时,被淘汰。
那我们当然是选择拥抱变化啊。单一职责原则定义已经告诉你了,对于一个类,应该仅有一个引起它变化的原因,重点是变化,什么才会引起代码变化?是需求,需求谁提的?是产品,产品的需求文档依据什么写的?是业务部门提出。技术人做的所有事情,最终都是要服务于业务的。 所以这里的重点还是贴近业务。
做到提前设计(事前)
如果能未卜先知,一开始设计的时候就国家、时区属性抽象到用户地理信息类中,则需求B就能够轻松实现了。如何能做到呢?
-
想办法提前搞到未来的需求文档。诶诶诶,先别说扯淡。这里想让你去做的是,贴近业务,多参与产品、业务的规划,了解未来的发展规划是啥,往哪个方向发力。在平时做设计时,可以注意思考怎么才能够更好支持这些需求。当然,这里存在一个风险:你搞到的这些业务规划,谁都说不好会不会突然推迟或停止了。所以我建议做到不给后续需求实现增加难度即可。(题外话:3-5年往上经验的工程师,你会发现你的核心竞争力已经不在纯技术能力了,对业务的理解也是非常重要的)
-
优秀的架构师,能够设计出具有前瞻性的架构,又或者能为一些多变的功能特性预留接口,使未来半年到一年的需求可以以较低成本迭代开发。这也是我们会高薪聘请有过相关经验的资深工程师的理由。(但如果你是一位优秀的架构师,估计也不会看这篇文章)
做到适应变化(事中)
在版本3的业务阶段,代码已经满足单一职责原则了,接到需求B后,一定要合理评估好,应该往版本4,还是版本5去演进。这能为后续迭代减少一些阻碍,避免系统逐渐成长为不可维护之”屎山“。
业务的规划明确了,到了提需求的阶段,突然被中断的可能性很小,此外对下一步的规划也稍微有迹可循了。所以这是最适合进行设计的阶段,需要认真对待。
具体一个可实施的办法:和产品、业务共同定义领域模型。可以简单就理解为一个业务实体关系图,应当要表达出几个关键点:实体、属性、动作、实体间的数量关系(几对几的)、约束关系(组合的还是聚合的)。没找到既合适又清晰的图,以合适的为准,如下所示:
这些关键点,很大程度上可以映射到程序中的类、属性、方法中,相当于是以业务为驱动去设计。这种做法可以让整个产研测团队的理解是一致的,让程序设计更符合实际业务。 领域模型制定的过程中,产研看问题的角度不同,观点就可能产生碰撞,这也可以帮助产出更合理的业务模型。(感兴趣的同学可以看下领域驱动设计的相关资料,作者还没系统研习过,不便多言,等学有所成,再来更新之)
但是需要提醒的是,”合理“、”适度“、”够用“这些词总是很玄乎,没有人告诉你度在哪,甚至设计结果的对错,都不好评估。所以这需要自身进行把握,只要你觉得如果这么做,以后团队的理解成本会低,迭代速度会快,后续要推翻重构的成本也不高,那便是可行的。
做到知错能改(事后)
如果前面两个阶段没有把握好,目前系统已经充满”坏味道“,则需要花力气去进行重构了。做法可以参考前面两点,也可以避免下面列举的部分的”坏味道“实现(你不知道怎么设计才合理,总该记得时不时对你动手的拳头吧):
- 留意一下你做这个需求最后提交的代码Diff。真的需要修改这么多个文件吗?
- 小明和你并行开发一个面向不同业务的需求,他先合并到master上线了,轮到你合并master代码时。为什么有那么多的冲突,可以避免吗?
- 一个类拥有了太多的属性和函数,一个方法写了太多的逻辑,难以读懂,难以修改。思考一下真的需要都内聚到一起吗?
- 一个类中,定义的属性、方法,是否都和本类有紧密关系?比如在用户类中,含有与活动信息相关,但和用户信息无关的方法。
- 如果你用了”通用名词命名的类“承载具体的业务逻辑,比如在一个Verify类中,校验用户信息、校验地址信息、校验活动信息。试试看能否按拆分到具体业务模块呢?
总结
单一职责设计原则不单是一个代码规范,更可以看作一种分类思想,在不同的场景都适用。
-
小到代码层面,类的设计:这个属性、这个方法该放到哪个类里面实现。
-
再到系统层面,
用户系统的职责应当如何定义?”某用户是否能进入游戏“的功能实现,应当由游戏系统还是用户系统来实现? -
再到公司层面,组织架构的调整:该团队负责的业务是否太杂,是否将某些业务拆分走,以便能在核心业务发力。
架构设计的最终目的是降低成本,单一职责原则所探讨的,是以合适的职责划分,降低每次迭代中无意义的内耗,提高效率。