如何尝试设计合适的代码结构
如何设计代码结构在我看来是一个挺主观的问题,因为代码的设计本来就不可能做到完美。但是如何才能尽量做到写出可维护代码,在我看来应该关注的有这么几点吧。
控制反转与依赖注入
我认为比较重要的设计模式可能就是控制反转(Inversion of Control)。依赖注入(Dependency Injection)是控制反转的一种实现(要实现控制反转还可以使用服务定位模式)。它的主要思路是使用一个单独的装配容器来获得某一个类的合适的实现,并将其实例赋予给另外一个所需要用到的类的一个字段。
解决如何创建对象
很多时候,当你在写面向对象的代码时,总是会去先定义它的构造方法,然后在需要的时候再去构造它。这个模式的问题我可以用一个简单的例子来说明。
你想要去吃一份美味猪扒饭,但是你没有配方,你可能需要以下步骤:
- 自己买猪扒、饭和调料
- 雇佣一个拥有对应猪扒饭配方的厨师
- 让厨师用你买来的材料根据配方制作
你,直接请了一个的厨师来帮你做饭。而实际上,使用了依赖注入后,你只需这个步骤:
- 找到一家拥有美味猪扒饭配方的餐馆
- 根据菜单下单
这个模式解耦了我跟厨师,我不需要通过雇佣厨师来获取特定的食物,只需要去特定的餐馆,调用接口对应的下单接口就行。
所以,使用依赖注入模式后,对象不再由应用程序代码主动 new 出来,而是由外部容器在适当的时机(如应用启动时,或第一次被获取时)创建并“推送”给需要它的组件,后续只需要调用这个对象对应的接口。
替换单例模式
在我看来,使用这个模式最大的原因是他替换单例模式。那么其实就不必从众多的 CSDN 上去找怎么样能够线程安全的创建单例这样的问题。单例模式几乎都是隐式依赖,而且普遍是可变的全局状态。虽然这看起来跟依赖注入没什么关系,但是很明显,如果使用依赖注入,你可以很快的把这个实例中的某个单例找到并改写,而不是通过SomeClass.instance查找调用的地方。
可测试性
因为获得依赖对象的过程被反转了,所以这个对象的依赖可以变成接口,其实体的具体实现就就可以多种多样。这样,对依赖这个接口的对象进行测试就变得很方便,因为我只需要注入一个接口的实体类。这种方法可以让应用不依赖外部设备或者网络来运行,因为应用不依赖于这些底层接口,那么我只需要为上层的业务逻辑提供一些可用的假数据,就可以对其进行测试。
函数副作用与状态
副作用是一个来自函数式编程的名词,在进行设计的过程,要时刻关注的一点细节应该就是函数副作用。副作用的一个作用就是变更应用的状态,这个应用可以是内部的(也就是副作用影响了本身的),也可以是外部的(通过进程等调用,控制外部应用的状态)。关注副作用,会改变以往面向过程的编程形式,转而考虑的是如何对一组行为进行合适的抽象并隔离行为所产生的副作用。
根据我以往的编程经验,随意使用赋值操作和IO操作是导致程序出现bug的概率最高的两个操作。而副作用的定义也很明确的指出,变更对象或者调用IO函数的操作是副作用。如果可以对这些操作进行管理,那么肯定会有助于减少bug。
在设计上,隔离副作用主要有两个方面考虑:
- 系统的状态变更
- IO 处理
系统状态变更
解决系统状态变更带来的副作用我主要是使用类似于 Redux 的处理方式,其主要思想是通过对行为的逻辑进行分类形成一组动作,而后通过对状态和动作进行映射形成的一组结构。其主要结构由以下三个部分组成。
- State:定义系统状态。
- Action:映射到下一个状态所需要的操作。
- Reducer: 完成操作后所需要进行的系统状态变更。
- Effect:对非变更状态的其他副作用集中处理的操作,同步外部系统。
这种设计不是银弹,但是可以很好的提供副作用应该执行的地方。State 对外是只读,对内则应该由 Reducer 进行变更,Action 关注是逻辑,而 Effect 则是用于同步外部系统。这样,每个模块实际上都只负责一部分的职责。
IO处理
对于IO处理,在设计的时候应该尽量使用类似 Promise 和 Future 等工具去处理IO,因为使用这种设计能够有效的避免嵌套回调,同时可以很直观的观察到输入输出,比如,向磁盘读取一组用户数据:
typescript
那么我使用的时候,只需要对users进行一个解包操作就能拿到里面的数据。
抽象——客体与主体,组合优于继承
在对模型(业务)进行抽象过程中,会有多种不同形式的抽象。以 Nest.js 为例,它把模型抽象成贫血模型,将数据和逻辑进行分离。而在领域设计中,则是更倾向于将模型扩充,形成充血模型。两种模式并无优劣之分,一个更面向过程,一个更面向对象。而我所关注的地方在于,在设计这些模型的过程中,是否有更好的职责划分,在领域驱动设计上,最终的关注点则是落在了贫血模型和充血模型应该怎么构建,从而引出了抽象的关键划分点,客体和主体。那么最终落实到当前的代码设计的话题上来,我认为能够正确的设计应该是需要分清客体和主体。
我们要按照充血模型来设计一辆汽车,那么这辆汽车应该设计成这个样子。
typescript
可以看到,这种简单的充血模型的设计是非常满足面向对象的,但是它忽略了一个问题,汽车是客体,人是主体,汽车不能自己跑,只有人才能驾驶它(主体和客体的抽象我称之为面向真实世界编程)。所以,我认为的正确的抽象应该是由 Driver 来开车,而不是由车来开车(携带自动驾驶的车呢?我认为现在不是讨论这个问题的时候)。这样我们只能把车设计成贫血模型,新增主体用充血模型。
typescript
这样我们也就可以引申出更多这种主体客体模式的抽象,这种抽象更有广泛意义,因为现实世界正实基于这种主体和客体所形成依赖。就比如说,假如我要设计一个电商系统中的订单模块,那么按照设计思路,订单要设计成贫血模型,除此之外,还要增加一个主体——收银员,因为,订单不能单独离开收银员自己进行结算。
上面这种“主体—客体”的划分目的是把“能力”从“物体”身上剥离出来,于是代码里不再出现“汽车会跑”这种继承式伪命题,而是“司机会驾驶汽车”这种组合式表达。既然能力被封装在主体里,客体只保留数据,那么复用就不再依靠继承,而是把不同主体按需组合:一个Driver可以驾驶Car,也可以驾驶Truck;一个Cashier可以结算Order,也可以结算Invoice。需要什么能力,就把对应的主体组合进来,既减轻继承带来的高耦合,也让单元测试可以直接替换一个“假司机”或“假收银员”。由此,组合优于继承不再是口号,而是“主体/客体”抽象下顺理成章的代码组织方式。
先分层再模块
当项目规模膨胀到“一屏装不下目录树”时,最先崩的往往不是编译器,而是人的大脑。我习惯用“两层刀”先切大块,再切小块:先纵向“分层”,再横向“模块”。
分层优先
无论前端还是后端,技术维度永远是“IO → 业务 → 应用”这条流水线。我把它压成三层:
-
基础设施层(Infrastructure)
只放跟外部世界打交道的代码:数据库、消息队列、文件系统、外部系统接口(HTTP API / RPC / WEB SOCKET)。 -
领域层(Domain)
把具体的公共业务抽象而成。所有业务决策(价格计算、库存扣减、状态流转、异常处理)都关在这一层里完成。 -
应用层(Application)
负责“把外部世界翻译成领域层能听懂的话”。全放在这里。它们只做三件事:验参、调领域服务、把结果再翻译成外部需要的样子。
这三层之间只能单向依赖:应用 → 领域 → 基础设施。通过依赖注入把实现“翻”过来,领域层面向接口编程,运行时再由 IoC 容器把具体实现注入进去。于是“领域”成了稳定的内核。
模块:垂直依赖、水平解耦
主要有这么三点需要注意:
- 模块是跟业务绑定的,如果没有业务,也就不必分模块。
- 其次,同层模块互不依赖,不同层只能依赖于下一层。
- 同层之间通过领域事件进行解耦。(这里要简单说一下,在前端里面,领域事件一般是通过路由的形式表示,比如,你有在模块A的一个页面,想要跳到模块B的页面,那么就要通过路由的形式跳转,在代码层面看来没有形成依赖,只是通过路由的形式进行了通信。)
能做到上面几点的话,基本上你的代码结构就已经是比较合理的了。
总结
本文围绕“写出可维护的代码结构”展开,从三个主线阐述了关键做法:
- 控制反转/依赖注入显式管理依赖与生命周期,
- 围绕副作用与状态构建清晰的执行边界,
- 以“主体/客体”抽象推动组合优于继承的设计取向。
- 先分层再模块,先纵向“分层”,再横向扩展“模块”。
本文的核心目的是提供一系列的编程思路,来把应用复杂度降低,时刻考虑以上三个要点,可能会有助于你的代码往更好的方向进行。