代码可复用性问题兼谈团队协作
简介
工作了一两年的软件工程师,大概没有不听说过“可复用性”的概念。可复用性可以从两个视角来体现:
- 创造者: 可以提供通用的服务和成果,别人直接在其工作的基础上构建自己的成果。整个过程不断叠加构建出恢弘的建筑。
- 使用者: 可以直接使用已有的服务和成果,无需重复创作,达到高效。
理性学科,比如数学、物理是遵循可复用性的生动体现和典范。因为大家都知道,不充分了解别人的成果,根本无法进行自己的工作,而且即使耗费大量时间和精力作出成果,如果与已有成果重复了,基本等于零。遗憾的是,工程领域里,却充斥着大量的重复创作和浪费,且很少有人察觉这种浪费。
当你想要一个小功能点的时候,从以下场景上想想:
- 你发现有一个函数能够实现想要的功能,一行代码调用就能搞定,心欢喜焉;
- 你发现有一个函数能够实现想要的功能,做一个简单的适配就能搞定,心戚戚然焉;
- 你发现有一个函数能够实现想要的功能,但参数不是想要的,得改改参数,抽离出一个子函数出来;
- 你发现有一个函数能够实现大部分功能,还需要做点修改才能适配自己的需要,于是在里面添加了一个新的逻辑;
- 你发现有一个函数能够实现,但这个函数做了太多事情,需要从中抽离出自己想要的部分;
- 你发现有一个函数能够实现,但这个函数做了太多事情,而且依赖比较复杂,从中抽离出自己想要的部分有点麻烦,但还可以接受;
- 你发现有一个函数能够实现,但这个函数做了太多事情,而且依赖比较复杂,从中抽离出自己想要的部分有点麻烦,但不困难;
- 你发现有一个函数能够实现,但这个函数做了太多事情,而且依赖比较复杂,从中抽离出自己想要的部分有些困难,而且容易导致问题,需要大量回归测试。
- 你发现有一个函数能够实现,但这个函数做了太多事情,而且依赖比较复杂,从中抽离出自己想要的部分很困难,不如重写一个。
- 你没有发现现有函数能够实现。自己重写一个。
以上场景,可复用性逐渐降低,而重复创造和浪费的概率逐渐增大。软件行业发展到至今,几乎不太存在最后一种情况,但很尴尬的是,据我所接手的业务工程,处于第一种情形的极少,处于前三种情形的也不多,大多数都落在了后六种情形上。
是什么阻碍了做到代码可复用性
现在,让我们来看看,是什么阻碍了代码可复用性。
违反单一事实原则
违反单一事实原则,可以说是阻碍可复用性的首要“罪魁祸首”。“单一事实”原则,可以说是中高级工程师耳熟能详的一个基本代码原则。遗憾的是,真正做到这一点的并不多。这体现了一个有趣的事实:越是简单的事情,越做不好。起床、吃饭都是极为简单而且基本没有“技术含量”的事情,人体天然能够支持的能力,然而,真正坚持专注做到做好的有多少人呢?
举出违反单一事实的例子非常容易,在代码工程里俯拾即是。随便拿一个函数,看看它干了什么事,想想它能够在什么情形下被复用。大多数已有的函数和类,不是把技术逻辑和业务逻辑混杂在一起,就是恨不得一下子把所有事情都做了,瀑布一泻而下,疑是银河落九天。
先不要谈什么设计原则或设计模式,先把“单一事实”原则切实做好。如何做好单一事实原则?
- 想清楚这个函数做什么事,遏制想做两件及以上事情的冲动; 克制!大家都知道产品设计要克制,过于臃肿的产品谁都不想用,但过于臃肿的代码谁又想去碰呢?
- 如果需要做两件事,拆分出两个可以组合的函数;
- 如果想要新增流程或逻辑,新增函数,然后在其中调用;
- 仔细定义参数,参数最小化;
- 每个方法不超过 50 行(除去空行);
- 拆分,新增函数,成为无意识的习惯!
惟有养成这样严格遵循“单一事实”原则的习惯,才能在项目工期很赶的时候,依然能够写出质量不错的不臃肿的代码。
参数过多
由于特别容易做很多事情的倾向性,因此参数往往过多。当你发现一个函数里的参数过多,这个函数很可能违反了单一事实职责。或者是,在某种程度上,逻辑拆分存在问题。
参数过多的直接后果就是:
- 如果你想复用这个函数,就得推敲和构造这么多参数;
- 你需要猜测每个参数的含义,与自己所需功能的相关性,过滤掉不需要设置的参数;
- 当传空的参数时,往往是一种不佳的代码体现; 而当传很多空的参数时,很难看;
- 理工科的人,需要一点艺术的熏陶。只懂逻辑不懂表达和设计的人只能写出难看的代码,虽然或许很管用。
领域逻辑没有抽离
说起来,软件工程领域落到实处的进步真是很慢(大概对于任何涉及多人协作的事情都是如此)。从最初的一团面条,到后来有人提出了
Controller-Service-Dao 分层理念,大家才开始有了分层抽离的概念,知道把参数校验写在 Controller 里,把应用业务逻辑写在 Service
里,把数据访问相关写在 Dao 层里。
不过,这样还远远不够。Controller 和 Dao 倒是清晰了,但 Service 还是很臃肿。为什么?因为程序员习惯于把各种业务逻辑、技术逻辑、业务流程都扔到
Service 里,哪怕有些技术逻辑是一个可以复用的工具类,哪怕有些业务逻辑是可以复用的领域知识。
DDD 是一种设计理念,但看上去掌握这种设计理念的人不多。实际上,我觉得 DDD 对代码编写更有启发。把 DDD
相关的领域知识,放到富血模型里或者领域层里。比如,对于一个安全业务工程,安全检测相关的逻辑,就很适合单独抽离到领域层里;比如,K8S
相关的资产,就适合把 Pod, Controller, Container Image 这些基础概念的关联关系抽离出来,放在领域层里,应用业务层只需要使用领域层的方法即可,而无需充斥在各种
Service 里。
DDD 的核心思想就是:领域知识是一个业务工程里的核心资产。领域层是需要持续沉淀而稳定提升的。如果系统要做大的技术重构,按道理领域层应该是不用动的,动的是技术层。对于复杂业务工程来说,一定要有领域层。
代码拆分太粗放
代码放置太粗放,也是导致代码可复用性差的一个因素。只按照 Controller - Service - Dao 来拆分代码,自然很多代码重担就落在了
Service 里,因为 Controller 和 Dao 的职责比较固定,逻辑也偏少。
更加细致的代码拆分是怎样的?
以下是业务层的:
- Controller : 仅作为请求转发层和参数适配层;
- Service: 参数校验与业务逻辑流程。很多人把参数校验放在 Controller 层,我觉得不妥。因为如果要复用 Service 的方法,参数校验也是要复用的;
- Dao: 数据库访问层。可以作为数据库中间件的适配和隔离;
- Model: 与数据库直接交互的数据对象,目前基本是贫血模型,只有属性;
- DTO: 数据传输对象,目前基本是贫血模式,实际上应该是富写模型,放在领域层;
- Helper: 业务辅助类,领域层。可复用的业务逻辑判断、业务流程需要放在这里;
- Exception: 异常处理和错误管理相关;
- Constants: 业务枚举,业务常量;
- Config: 系统配置相关;
- Loader:系统初始化相关;
- Cache: 业务缓存;
- Components: 业务组件;
- Receiver:数据消费接收器;
- Handler: 事件处理类;
- Context: 长流程里的上下文语境类;
- Strategy: 同一业务目标的不同业务处理逻辑的策略类。用于分离不同业务的差异很好用;
- Plugin: 业务插件,用于扩展;
- Listener: 监听器;
- Scheduler: 定时任务调度;
- Job: 业务任务;
- Producer: 任务生产者;
- Consumer: 任务消费者;
- Switch: 开关控制;
- Wrapper: 包装器;
- Adapter: 适配器。
以下是技术层的:
- Concurrent: 线程池、分布式锁等。
- Util: 工具类,可复用的技术逻辑。
- Interceptor: 拦截器,用于流程修改和业务逻辑转换。
- [De]Serializer: JSON 自定义序列化与反序列化
总之,当仔细去思考业务中的大大小小的关注点,把关注点的粒度最小化,就能发现更多放置代码的层次和地方。这并非是固定不变的。
还有一个问题: 整体工程结构是按技术结构拆分还是按业务功能拆分?
- 按照技术结构拆分,然后在每个技术子包里有业务拆分的子包。益处是,技术结构清晰;弊端是,业务实现则需要从各个子包里寻找拼凑出来,对业务不友好;
- 按照业务功能拆分。每个业务下有自己的技术子包。益处是,很容易定位一个业务的所有相关实现;弊端是,公共的部分容易散落在具体业务里,且子包数量会膨胀。
目前基本是按照技术结构拆分,符合研发同学对设计理念的认知。也许有一天,会开始按照业务拆分。
不合理的访问级别
由于倾向于把很多业务逻辑都放在 Service 里,自然很多潜在可复用的方法就会被设置成 private, 外部无法复用。当要复用时:
- 可复用的方法放在很大的 Service 和 Flow 里,即使可以复用,但依赖这么大一个类或不恰当的层次,心有戚戚然焉;适合被依赖的最好是一个具有单一职责的小组件类;
- 可复用的方法是 private,需要改成 public ,增加相应接口定义。
这种情形,通常就是代码放置的层次和地方不合理。应该抽离到一个单独的小组件类里,设置为 public 方法。
惰性
惰性是人固有的生理特性,也是阻碍人持续进步的最大的因素。或许是老天爷开的善意玩笑吧。毕竟,生物的基本设定就是,能够躺着就不想坐着。
简单的事情如果做不好,基本可以归为惰性的缘故。就像单一事实原则,到底是这个原则实际上很难做到,还是因为惰性而不愿意主动多走一步?
缺乏可复用代码的意识和技巧
没有思考过,没有学习过,没有被教授过,没有练习过,写可复用代码。这样,写出的代码就是“山大王代码”。这是我的地盘,只能我动,其它人只能远观不能亵玩焉。
缺乏写可复用代码的意识,如果异常处理做得比较周密,还能成为一个合格的程序员,起码能保证基本的工程质量。但别指望他能做一些对团队整体有益的事情。
如果有写可复用代码的意识,而缺乏写可复用代码的方法和技巧,那么就从做好单一事实职责开始吧!
如果要有更强的写可复用代码的意识,每写一段代码,都会思考,哪些部分是通用的,哪些部分是差异的,然后努力将公共的或者差异的部分提取出来,这样,才能逐渐提升写可复用代码的意识和技能。
缺乏分离与解耦差异的技能
如果要达到更高层次的可复用性,则需要掌握分离和解耦差异的技能。善于把业务逻辑和流程进行分离和解耦,让每个组件自行负责各自的工作,让关注点清晰可辨,让交互自然流畅。
为什么代码难以复用?正常情况下,一段代码总会有一些特定差异的逻辑和一些通用的逻辑,而且差异的逻辑往往潜藏在代码的任任意位置。由于缺乏将特定差异的逻辑提取出来的技巧,就会导致代码很正常但就是难以复用。
函数式编程和设计模式,能够很大程度上提升代码的可复用性。函数式编程,用函数来表达差异;设计模式,确定了明确的组件职责,让交互更加自然,容易扩展。
细小关注点没有抽离
软件系统中充满着各种大大小小的关注点,技术关注点,业务关注点。哪怕是一段中文乱码处理,也是一个小关注点。细小的关注点没有抽离出来,就会导致高度的重复。我是见证了职业生涯所遇到的最高程度的代码重复艺术。
瀑布流代码的益处与弊处
当然,不能只是负向批评。难以复用的瀑布流代码也是有其益处的:读起来贴合程序员的自然思维。好的瀑布流代码读起来很流畅。遗憾的是,
很少瀑布流代码能够做到这一点。大多数瀑布流代码都会面临后期的各种修改,逐渐因为各种分支语句导致水滴四溅,甚为壮观唯美。
与其益处相比,其弊处更多:
- 如果代码只写一次,那么很清爽;
- 如果代码会被改动多次,每次改动都会影响整个流程,每次都需要大范围的回归测试;
- 多个人一起改动,容易产生冲突,在代码合并和解决冲突上会耗费很多时间和精力。
团队协作
为什么代码可复用性如此重要?
软件开发是团队协作的生动体现。然而,
团队协作并不是“你在我代码上修修补补,我在你代码上修修补补”这种简单低级的协作。何为真正的团队协作?每个成员都充分分享和贡献自己的创作和成果,同时每个人都能从团队其它成员的创作和成果上获得新知、经验和工具,高效促进自己的工作。
代码可复用性体现了团队协作的高效程度。
- 每个成员都致力于创造可复用的构件;
- 每个成员都把别人写的代码当成自己写的代码,改进和测试所使用到构件;
- 大多数时候,并不需要从头写起,而是可以直接在某个基础上开始工作;
- 如果要修改,则是新增而不是在里面修改,即符合开闭原则。
代码可复用性差,那么研发效率不会高到哪里去。
如何衡量代码可复用性? 非常简单。当你开始构建一个功能时:
- 过程:想要一个基础功能时,是否有现成可用;是否专注于构建自己所关注的业务自身相关的逻辑,而不是还要构建技术逻辑,构建一些基础业务逻辑,处理复杂的技术和业务交互;
- 结果: 最终写了多少行代码。代码越多,说明现有工程的可复用性越差(前提是你已经通读了整个工程的代码,知道没有可复用的,或者复用起来很麻烦)。
小结
软件开发是团队协作的生动体现,代码可复用性体现了团队协作的高效程度。本文探讨了阻碍代码可复用性的因素,以及如何做到代码可复用性的方法和技巧,希望对大家有所益处。
PS: 细想一下,做到代码可复用性还真的不那么容易:
- 严格遵循单一事实原则。保持克制。避免一个方法做两件事;
- 最小化输入参数;
- 领域层逻辑抽离;
- 识别小业务组件并抽离,设置合理的访问级别;
- 细致放置代码到合适的地方,合理的包拆分;
- 细致识别小关注点并分离出来;
- 思考通用与差异的部分;
- 练习分离和解耦差异的技巧:函数式编程与设计模式。