更多文章请参见:我的blog
0. 背景
本文主要讨论的内容是代码层面的内容。之前将软件行业的认知分为七个层次,这里主要的内容集中在代码层面,会涉及一些程序、软件层次中的内容。适合与刚入行的同学阅读。为初级工程师快速的成为中级工程师提供一条道路。
本文从几个常见的问题入手开始讨论写代码过程中可能遇到的问题(第一节),再讨论编写代码中除了常见问题还应该考虑哪些问题(第二节),讨论完遇到的问题以及要考虑的问题之后开始开始讨论哪些方式可以解决这些问题(第三节),到最后以实践的角度落地这些解决问题的方法(第四节)。以深入浅出的方式说明写出好代码并不是那么的难。
1. 代码为什么越写越乱?
在新写的代码和老代码的改造/重构过程中,总会发现因为技术、业务将一个简单的问题弄的越来越复杂。从而导致代码越来越难看,越来越不能被理解。那么有哪些原因导致代码越来越混乱?
1.1 结构化程序设计的主要特点流程式代码
从学习过程来看,任何一个研发人员都是先从面向过程编程开始学习变成的。在学习的过程中一个最主要的过程就是使用顺序的方式将所要实现的内容实现了。
在软件工程中我们学习过软件设计中需要包含模块/组件,但是很多软件从业者没有办法将这个概念落地到代码编写过程中。因为没有办法划分模块的边界、确定模块的意义、描述清楚模块间关系,所以就不进行模块的拆分。很多从业者不能理解模块/组件到底应该落在软件认知7层模型中的那一层也是影响开发人员不进行模块划分的原因。
1.1.1 流程化的代码编写方式有问题吗?
经常遇到一个文件打天下(上帝类)的情况,例如在mvc下的service是不是上帝类,该做的不该做的都放在这个里面?
一个Method走天下,还是例如在mvc中一个业务都直接写在service的一个方法中。一个方法一般情况下都在200行以上,这种就很容易形成铁桶一块的问题。根本没有办法修改这个方法,如果要新添功能就直接在方法中加一块代码。
1.2 MVC是万能的?
在WEB编程兴起的年代,也伴随着MVC模式的兴起。所以,大家在学习WEB编程时都在学习和使用MVC模式。而MVC的特点是它由Model,View,Controller组成。而在微服务时代View都由前端实现,后端基本不用操心这方面的内容。Controller中主要做转换,控制,安全。那么业务逻辑应该在哪里完成呢?在Model中吗?这就是MVC没有办法解决的问题。
1.2.1 面向对象
在MVC中实际实现过程中,Model中由Entity、Service和Dao组成。Entity负责业务实体,Service负责业务处理,Dao负责持久化。而Service中写的代码都是以面向过程的方式进行编写的。所以Service和Model的概念都是冲突的。Model代表的是业务模型,Service根本就没有办法代表业务模型。也没有办法说用Java写的代码就是面向对象的。
1.2.2 代码的边界在哪里?
上面说到的一个文件打天下,一个方法打天下是很明显的代码的边界是业务流程。并不用吃惊,因为很多同事嘴上说着做技术,其实写代码的时候都是写的业务流程代码。
这里只想说这种代码边界是错误的。原因有这么几点:
- 业务流程的公用性比较小,所以导致方法,文件的公用性更小。从而导致代码无法复用。
- 业务流程没有拆分,无步骤则代码不易读。造成很难维护。
1.2.3 分层关系
在MVC的优点中做了一项叫做开闭原则:对扩展开放,对修改关闭。做了不同层次之间的封装,做了一层隔离。
1.3 软件复杂度的三个来源:规模,结构与变化
《解构领域驱动设计》中张逸老师说到软件复杂度的来源:规模,结构与变化。这三项最终都会落在代码中,例如业务会不断的发展,不断的增长,所以代码的规模也会不断的增长。稍微上规模一点的业务系统,都会牵扯到各种各样的实体,以及实体之间的关系导致结构的复杂度提升。业务是否会演进,业务演进就会带动代码的变化。
在代码层次中怎么应对这些内容呢?
1.4 总结
从结构化变成深入到软件的复杂度,一路上都是各种问题来影响代码的编写过程。考虑每一个方向都有可能和其他方向的代码有质的区别,我们这里并不讨论这几个方向深入之后会产生什么样的代码,我们这里讨论公共的一些好代码的写法。
1.4.1 问题
大家都习惯了流程化的代码编写方式,并深受MVC之毒(其实并非MVC问题,而是不深入思考)。在这种前提下又有这么复杂的问题需要编写代码来解决。代码写的烂是不是变成了正常事。
1.4.2 解决问题
-
分治思维
结构化程序设计的主要特点是抛弃 goto 语句,采取“自顶向下、逐步细化、模块化”的指导思想。
结构化程序设计本质上还是一种面向过程的设计思想,但通过“自顶向下、逐步细化、 模块化”的方法,将软件的复杂度控制在一定范围内,从而从整体上降低了软件开发的复杂度。--李运华《从零开始学架构》
-
不断的实践设计模式
学会写OO代码,并能够理解KISS,DRY,SOLID,最少知识、向稳定依赖原则。并能够在工作过程中不断的实践这些原则。
下面主要说明落地这两个方向会遇到怎样的问题?以及怎样解决这些问题?
2. 怎么做到高内聚低耦合?
现代软件的代码第一目标是可读性,其他的事情可靠,性能,安全等都是可以通过其他的方式解决的。所以,在编写代码的过程中第一要务是让代码能被别人看懂。
如下图随心所欲的做事和有规则的做事,有很大的区别。区别就在于怎么进行分类整理?
治理
下面逐步深入讨论一下代码怎么做到高内聚低耦合。第一步讨论划分代码模块(函数)时可能遇到的问题,第二部讨论划分模块(函数)时是不是应该考虑所有原则,最后以统一的方式进行解答并引申到下一节的结构化。
2.1 模块/组件的定义
在编写代码过程中并不是只写方法(函数)就可以,还需要进行文件划分,Package划分。这些在某种意义上就是不同层次的模块划分。但是划分过程中可能会遇到这段代码应该放在文件A中还是文件B中的问题,这里会从不同的层面列出问题,让大家对这部分有更深入的思考。
2.1.1 定义
-
模块的责任怎样确定
负责模块中的事务吗?
负责模块中的持久化动作?
模块中的业务怎么确定应该在模块中还是在模块外? -
模块之间的关系是什么样的?
如果两个模块有关系,他们是不是直接进行调用?
模块是不是落在不同的层次中?层次之间的依赖关系是不是服务的关系?层次是洋葱架构层次还是上下分层?
使用函数是编程中的处理类传递还是使用DI的方式进行依赖传递? -
模块中任何地方都可以调用其他的服务吗?
对外能力(方法、功能)怎么确定?
内部能力(方法、功能)可以被任何其他模块调用吗?
2.1.2 工程代码与算法代码的区别
-
算法代码代表着一个功能
所有的代码都需要写在一起,因为必须在当前位置调整指针位置,调整当前值内容等。 -
工程代码是分步骤的
一个业务写一个流程就可以解决所有的业务问题还是按照规则去完成高内聚低耦合?
领域?微服务?限界上下文?领域?OO(面向对象)
工程代码是给人看的,所以第一要务是让人能看得懂
2.2 原则
除了SOLID之外还有KISS,DRY,BASE,约定大于配置,奥卡姆剃刀等等原则。那么在哪里使用这些原则,有没有反模式?
2.2.1 写代码的时候是面向复用编程,还是面向业务编程?
很多代码编写的过程都是BA/PM输出需求,然后开发进行代码编写。那么开发顺着需求的思路进行实现过程中是不是就变成了按照业务进行编程,再结合之前的问题一个Method打天下。就变成了业务流程写在一个方法中。那么怎么面向复用编程?
2.2.2 最少知识
接口规则怎么影响高内聚与低耦合?数据库中的2NF(部分子函数依赖)是不是会影响接口的定义?
内部实现的内容不应该通过参数被暴露出来?
2.2.3 单一职责
举一个简单的例子:
- 在业务的参数校验中能不能进行服务间调用完成业务?
- 上面说到工程代码是分步骤的,步骤之间的责任是否定义清晰?
- 在Controller的Method中进行业务编写是否合适?Controller中应该干什么?
2.2.4 圈复杂度
圈复杂度大说明程序代码的判断逻辑复杂,可能质量低,且难于测试和维护。复杂度越高代表读懂代码越难,其他人读懂代码代表着是不是可以维护。不过圈复杂度只能代表代码层次的可读性,不能代表程序层次的可理解性。
圈复杂度
这里说明最简单的降低代码圈复杂度的方法:不要if中嵌套if,不要循环中嵌套循环。下面解决方案中会有更加完善的解决办法。
2.3 拆分+明确解决问题
从原来的杂乱无序,到结构化定义。其实就是使用拆分+明确边界的方式进行解决。拆分和明确可以利用大规则:抽象、分解和知识来进行。最需要的就是以这种方式进行思考,将这种思考模式应用到软件的各个层次。
治理方式2.3.1 实践
-
空行的意义
一个方法一个函数中,空行的意义是隔开不同步骤之间的内容。在一些不用拆的很开的代码块之间有需要说明他们是不同的意义的代码块之间用空行隔开。 -
方法名与领域命令之间的关系
面向对象课程中教过方法就是对象的行为。那么行为是鸭子叫?还是你打了鸭子一下鸭子追着你叫?所以这里应该是需要了解对象是需要处理什么样的动作,然后动作中需要有什么处理流程。 -
方法的阶段性划分
代码编写范式就是设计模式,但是设计模式中没有说明一个业务代码应该怎样拆分步骤。这里给出一个作者认为通用的方法阶段拆分流程。- 准备参数
将参数校验中需要的数据准备好。 - 参数校验
进行参数的校验动作,例如在修改动作中查找原对象是否存在,对象的字段是否符合业务意义。 - 业务步骤
业务动作,业务动作可能是多个步骤。例如在电商中购物车生成订单的业务步骤获取商品信息,暂存商品信息,生成订单,生成订单项,通知店家等等。 - 返回结果
返回处理结果
- 准备参数
3. 代码怎么写才能不乱?
上面提了那么多问题,这里就开始说明怎么解决这些问题。其实治理一件事情很简单就需要处理三件事情即可:
-
明确事务边界
下面以分包模式的说明进行讨论。 -
明确事务间的关系
下面以金字塔原理的方式解决。 -
明确事务演进的方向即可
这个其实在代码这个层面上比较少,所以就不进行说明了。
3.1 分包模式
软件工程的发展过程其实就是不断的明确包(组件)的职责与划分方法的发展史。清晰架构是到现在为止作者看到最新的一代分包模式,如下图所示。
[图片上传失败...(image-6d64ae-1659096072391)]](https://img.haomeiwen.com/i2454595/f05c26388a7d99b0.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/740)
这里的内容不进行详细讨论,如有兴趣可以自行检索。这里就说明通过将业务、业务与技术链接、技术之间通过模块定义以及依赖关系定义的方式拆分开。以领域模型管理业务中的复杂度落在代码就可以保证领域中的代码的单一与简单,以各种组件将业务与技术关联、技术代码拆离到不同的组件中。
3.2 金字塔原理
金字塔原理结合清晰架构对于层次与模块的划分。
-
结论先行
应用层负责聚合领域服务来完成应用的业务,在编写这层代码时可以以结论先行的方式进行。即应用层只进行整体业务流程的编排工作,具体的业务操作在领域服务中完成。
这样基本上看了应用层的代码就可以知道这部分业务的整体业务流程是什么样的。 -
中心思想明确
以领域模型的方式进行中心思想明确,每一个领域模型都有它自己的要处理的业务,并且不牵扯到其他的业务。以领域模型能力暴露的方式控制了模块的边界。 -
先全局后细节
以不同的层次的职责与依赖关系来管理细节的递进关系。通过不断的细化,让上帝类消亡。
4. 组织模块的方式?
应对软件复杂度的方式最有名的就是DDD,但DDD并没有实际的代码编写方式的指导工作。而Cola是DDD的一种比较全面的代码落地框架。
充血模型的问题可以参见DDD 中的几个困难问题。所以这里没有以充血模型进行。根据作者浅薄的理解对拆包进行了些许的变化,如有任何问题可以联系作者。
.
├── cola-archetype #cola核心部分
│ ├── config #配置管理
│ ├── common #公共部分
│ ├── adapter #接入层
│ │ ├── controller #http接口
│ │ ├── rpc #rpc接口
│ │ └── amqp #消息队列消息入口
│ ├── connector #外部代用
│ │ ├── sms #短信接口
│ │ ├── email #短信接口
│ │ ├── amqp #消息队列消息出口
│ │ └── db #数据库
│ ├── app #应用层
│ │ ├── AAA应用能力 #负责CQRS和Event的事项处理,以及应用业务流程的整体控制。
│ │ └── BBB应用能力 #负责CQRS和Event的事项处理,以及应用业务流程的整体控制。
│ ├── domain-service #领域服务层
│ │ ├── XXX领域服务 #负责一个领域中的对外能力的暴露。
│ │ └── YYY领域服务 #负责一个领域中的对外能力的暴露。
│ └── domain #领域层
│ ├── XXX领域 #XXX的领域分包
│ │ ├── entity #负责这个领域中的实体定义。
│ │ ├── event #负责这个领域中的事件定义。
│ │ ├── handler #负责这个领域关心的事件的处理。
│ │ ├── service #负责这个领域中的能力的提供。
│ │ └── XXXAggregateRoot[.java/.go/.python/...] #领域聚合根,可能和领域服务层有些冲突
│ └── YYY领域 #YYY的领域分包
└── cola-components #cola组件部分
├── dto #dto
├── exception #exception
├── extension #extension
└── test #test
5. 总结
《重构》中有非常完善的代码好的样子和坏味道的样子。本文主要讨论的是代码管理以及代码编写中的内容。总结就是代码需要以先全局后细节的方式进行编写。
6. 参考
清晰架构
金字塔工作法
重构
圈复杂度
DDD 中的几个困难问题
网友评论