大型项目总避免不了各种模块间相互依赖,如果模块之间耦合严重,随着功能的增加,项目到后期会越来越难以维护。今天我们一起来思考下,大家常说的代码解耦到底要怎么做?
# 依赖是怎么产生的
既然要研究怎么让模块解耦,那当然要从根源来分析:依赖它到底从何而来?
依赖其实是在我们想把代码写好的那一刻开始产生的。为什么这么说呢?因为大多数代码都是可以通过像流水线一样写下来,最终变成一个几千行的函数、几万行的单个文件。这个时候甚至没有拆分成模块,也就更谈不上所谓依赖和解耦了。
忽然有一天,我们发现这种堆屎山的日子实在过的没有意思,开始研究怎么将一座大屎山拆成几个小屎山,然后再一点点清理干净。依赖的产生,就在我们一拆多的这个过程伴随出现的。
# 接口管理
当我们开始进行代码优化的时候,最先想到的就是将某些通用的功能抽象成单独的模块,通过提供接口这样的方式来给到需要的地方使用。
为了避免过度设计,我们会基于现有和可预见的需要进行设计。但日常的开发中,不可预见的问题定位和调整却占了大部分的时间。
例如,在做 To B 项目的时候,我们设计了一套完整的 API 给到对方,开始的时候大家都会按照这套接口来配合开发,其乐融融。突然有一天,老板拉了一个大客户,说这个客户的用户量会很大,必须要好好配合。老板一走,大客户马上化身甲方爸爸,说他们的接口已经写好了,友商都是按照他们的格式接入,都上线了。
遇到这种情况,通常我们会新增一个适配层,专门用于我们的服务和甲方爸爸之间的适配。
说了那么多,依赖在哪里呢?
依赖其实在接口设计完成的时候就出来了,虽然这是我们自己设计的接口,但它依赖于上游按照约定来调用。而上游有调整的时候,我们是需要跟随者适配或者调整的。
或者举个小一点的例子,我们在项目中使用了一个较出名的开源库。某一天该开源库升级版本了,新的版本不兼容旧的版本,同时声明旧的版本不会再继续维护了。这意味着如果我们不升级版本的话,后续旧的版本出现了 bug,我们只能自己啃源码来修复了。
这是来自于“甲方按照约定接口来调用服务”、“乙方按照约定接口来提供服务”的依赖。
# 状态管理
由接口管理产生的依赖通常来自外部,而应用内部也会有依赖的产生,常见的包括状态管理和事件管理。我们先来看看状态管理。
一个应用程序能按照预期正常运行,必然无法避免一些状态的管理。最简单的,生命周期就是一种状态。程序是否已经启动、功能是否正常运行、输入输出是否有变化,这些都会影响到程序的运行状态。
由于程序会有状态变化,因此我们的功能实现必然依赖程序的状态。例如,只有用户登录了才能进行更多的操作、订单产生了才可以进行撤销、界面渲染完成了用户才可以点击,等等。
从代码可读性和可维护性角度来看,面向对象编程近些年来还是稍胜于函数式编程,面向对象的设计本身就是状态设计的过程,而某个对象的运行结果,也会依赖于该对象的状态。
这是来自于对某个程序“按照预期运行”进行合理设计而产生的依赖。
# 功能管理
当我们根据功能将代码拆分成一个个模块之后,功能模块的管理也同样会产生一些依赖。
管理系统中最常见的就是面板的管理,对于每个面板来说,它应该只关心自身的状态。产品设计会要求我们在打开某个新的面板的时候,关闭其他面板;或是在点击面板以外的地方,关闭当前面板。这会涉及到面板与面板以外界面的通信,一般来说我们可以使用事件的方式来管理。每个面板在创建的时候,都需要监听外界的一个点击事件,并判断点击区域落在面板外面的时候,触发关闭。
某一天,产品提了个需求,所有的这些面板关闭的时候都要有一个动画效果,至于这个关闭动画的持续时间,要根据点击位置与面板的距离来计算。我们需要在点击事件触发的时候,把点击的位置告诉监听对象。
于是,我们全局搜了所有该类型事件的触发节点和监听节点,一一进行调整。
这是来自于对某个功能“不会发生变更”而产生的依赖。
# 依赖来自于约束
为了方便管理,我们设计了一些约定,并基于“大家都会遵守约定”的前提来提供更好、更便捷的服务。
举个例子,前端框架中为了更清晰地管理渲染层、数据层和逻辑处理,常用的设计包括 MVC、MVVM 等。而要使这样的架构设计发挥出效果,我们需要遵守其中的使用规范,不可以在数据层里直接修改界面等。
可以看到,依赖来自于对代码的设计。
# 依赖可以解耦吗
既然依赖来自于设计,为什么我们又常常说要进行模块间的解耦,降低模块间的依赖呢?
# 依赖的划分
我们先来看看一个问题,所有的依赖都需要解耦吗?
其实我们能看到,不合理的设计会导致代码间相互依赖,耦合严重。这种情况下,我们可以理解为产生了不合适的依赖。
而通常我们所说的设计解耦,则是通过合理的设计,恰到好处的职责和边界划分。此时,同样会产生一些约定,但这样的约定可以更好地管理我们的代码,此时可以理解为产生了合理的依赖。
因此,回到前面的疑问:既然依赖来自于设计,为什么我们要通过设计来降低依赖呢?显然,我们想要减少的,是不合理的依赖。而通过合理的设计,可以进行恰当的解耦。
# 无状态的函数式编程?
每个程序员对函数式编程都曾抱有过幻想,写多了面向对象编程的代码,对一些状态的管理和维护感到心烦。而无状态的函数式仿佛是白月光,可远观不可亵玩。
但即使是基于函数式编程设计的语言,写出来的功能也无法逃脱状态管理的命运。像 Clojure 编写的 Storm,也需要进行消息队列的管理、重启后服务的恢复等一系列状态管理。
在前端领域,React 同样基于函数式编程,但框架同样带有生命周期这样的状态。用 React 来实现的应用也依赖状态,因此同样产生了 Redux/Mobx 这样的工具来进行数据状态的管理工具。
应用程序无法离开状态的管理,是否意味着我们不需要函数式编程呢?并不是这样的。相反,我们需要对功能模块进行划分,划分出有状态和无状态的功能,来将状态管理放置到更小的范围,避免“牵一发而动全身”。
在这里,我们进行了状态有无的划分。
# 单向流的数据管理?
代码解耦的方式,其中也包括了使用单向数据流这种方式。
不管是 React 还是 Vue,都提供了单向数据流的管理工具。由于一个应用中,各个功能间都会有一些相互间的数据依赖,为了避免模块间的直接依赖,使用单向流的方式,可以将一些非模块内闭环的数据通过有序、单向的方式进行捆绑。通过这样的方式,模块之间的依赖解除了,调整为模块与数据流模块之间的依赖,代码的耦合程度得到缓解。
在这里,我们进行了模块内外数据的划分。
# 服务化
服务化,是系统解耦最常用的一种方式。
通过将功能进行业务领域的拆分,我们得到了不同领域的服务,常见的例如电商系统拆分成订单系统、购物车系统、商品系统、商家系统、支付系统等等。
而如今打得火热的“微服务”,也都是基于领域建模的一种实现方式。
在这里,我们进行了业务领域的划分。
# 模块化与依赖注入?
相比于针对系统设计的服务化,同样有针对功能设计的模块化。
在前端领域,同样可以根据功能拆分为表单功能、列表功能、面板功能等,通过给这些功能设置边界、封装成独立完整的模块,可以将功能与功能之间的依赖降到最低。同样的,根据功能划分的方式,我们还可以将功能拆分成渲染层、数据层、网络层这样的模块。
而配合依赖注入的方式,我们在使用这些功能的时候不再需要单独对这些功能的状态进行维护,同样实现了功能模块间的解耦。
在这里,我们进行了功能应用的划分。
# 结束语
到这里,你会不会有点疑惑,说了半天好像什么都没说?我当然知道要合理设计啊,但什么才是合理的设计呢?
架构设计没有银弹,系统的复杂度、使用场景、用户群体、机器性能等都会影响决策,“具体场景具体分析”才是最优解。
而我们能做的,就是多思考、多参考、多分析、多尝试,沉淀下来的经验和思考方式才是最实用的工具。