前端架构之模块依赖关系

1,227 阅读17分钟

项目可维护性之模块依赖

前言

什么是项目的可维护性呢,我想可以比较简单地形容为,项目在日常迭代开发中,你不会整天说这个项目真的就是一坨*,维护起来真难受。对于项目的可维护性而言,有很多的方面影响,例如说

  1. 需求不是朝令夕改,天马行空
  2. 代码质量过关,例如说如果一个项目的变量都是拼音或者a , b , c这样命名的,我想,你肯定要骂人。
  3. 文档非常完整,代码里不会有什么隐藏逻辑
  4. TS类型完善
  5. 。。。。。。

而对于如何提高项目的可维护性,则有许多方面,今天我们主要讨论的是,从模块依赖关系入手。

详细要点

依赖的重要性

对于项目的可维护性来说,相比各个模块内部的可维护性,模块之间的依赖和耦合关系更为重要,毕竟模块内部实现的再差,只要他对外是稳定的,对于整体项目而言没有差别,没有人关系node_modules里的各个module实现的如何吧,而且模块内部实现的再差,影响是局部的。而一旦模块依赖关系差,改一发动全身,会导致修改越来越难

在每个项目中,都会被有意或者无意间分出众多的模块,而模块间的耦合程度、依赖关系,就成为了对于项目的可维护性最为重要的东西了。

在正常迭代开发中,大家都或多或少知道一些耦合性强可能带来的坏处,如后续不易迭代,无法复用等,但是我们在开发中,仍然无法避免高度的代码耦合,原因很简单,高度的代码耦合符合正常的开发思维,然而即使你小心翼翼地处理成松散的耦合性不那么强的模块关系时,错综复杂的依赖关系仍然等待着我们去处理。

稳定依赖原则

yilai1.png

我们可以先看下这样的一张简化版的依赖图片,假设在项目中,ABCD模块都依赖于E模块,或者反过来说,E模块被ABCD模块依赖着。此时如果说E模块需要修改,我想这不会是一件容易的事,因为你需要知道ABCD都依赖了E模块哪些功能,你必须小心翼翼地去修改。结果好不容易改好了,又通知你去修改E模块,可能你这时候内心就有很多想法了。

所以从这个小例子可以看出,我们希望E模块是稳定的,别轻易去改动它,因为改它的成本,比改ABCD模块高多了,所以在设计之初,我们就应当遵守 稳定依赖原则,亦即依赖关系必须要指向更加稳定的方向

区分哪些更加稳定,哪些更加的不稳定

区分模块的稳定性很重要。一般来说,每个项目不稳定的原因都来自于一直变幻的需求,当需求不再迭代变更时,那么项目的可维护性也就失去了意义,所以可以说更加靠近业务侧的偏向于不稳定,而远离业务侧的则偏向于稳定。

最偏离业务侧的,当然是我们依赖的整个变量环境和系统api,我们不可能在会认为Array.map在哪一天会出问题或者说背离他原有的功能。我们应该且只能放心地使用他们,依赖他们。

接下来会是项目中一些通用的分层模块,比如我们常用的网络请求模块,通常来说,它是稳定的。虽然它们确实有可能变化。但它仍比起其他的模块来说,更加的稳定,而对于这种模块,我们在设计时,应该尽量让它符合 开放封闭等原则。

接下来是业务逻辑,在项目中,业务逻辑也分成两块,一块是比较核心的,比较基础的业务逻辑,另一块呢,则是更加接近业务的业务逻辑,可能有时候会把他叫做特殊逻辑。

核心业务逻辑,是你的项目中最基本的业务逻辑。例如在交易系统中,买入卖出会是一个基本的业务逻辑。而相对的,这些业务逻辑会比较稳定。

剩下的会是更加接近业务的业务逻辑,或者说特殊逻辑。例如说交易系统中,假设每天1-3点不可以交易,这就是一个比较特殊的业务逻辑。

最后也是最不稳定的,当然是程序的UI部分。我相信大家都遇到过,设计临时改UI,或者说下个版本就又换个按钮颜色。在大多数前端看来,一个需求就是业务逻辑+UI组成,但实际上,UI只是业务逻辑表达的一部分。毕竟除了UI,业务逻辑还可以允许在node端,或者React Native,或者单元测试里。所以某些UI上的区别,如果有需要,我们应该对不稳定的UI部分封装出来,而不应该耦合在业务逻辑里。不过因为我们目前使用的都说MVVM框架,是数据驱动视图,所以UI是比较自然地被区分出来了。

所以,在整体项目或者模块设计时,遵循单向依赖原则,区分稳定的模块和不稳定的模块,将让不稳定的模块依赖稳定的模块,这对于项目的可维护性有非常明显的好处。

抽象组件

从刚才的例子中我们,可以看出,我们希望于模块间的依赖关系总是指向更加稳定的方向,但在具体开发中,或者说实际的开发过程中,想要达到这样的依赖关系,我们需要怎么做呢。 yilai2.png

在这个图中,我们可以看到稳定的模块C却引入了对经常迭代的模块D的依赖,这明显违反了我们上诉所说的稳定依赖原则,而这将导致D模块在每次迭代时的难度大大增加,因为每次修改都得考虑到稳定的模块C和依赖于它的其他模块A和B。对于这种情况,我们必须打破C和D之间的依赖关系。所以就必须了解清楚,CD模块为啥存在着依赖关系。在这里我们假设是因为 C模块依赖于D模块的一个功能,Function calculateSalary。

yilai3.png

如上图所示。那么这时候我们可以这样设计,创建一个新的抽象模块,并且在其中有一个抽象函数接口,abstract calculateSalary,然后再让D模块去具体实现这个calculateSalary。这时候依赖关系就变成了下图所示。这样一来,CD间的依赖关系就消除了, 他们共同依赖于E模块,而E模块又比D模块更加地稳定,所以这也符合我们的稳定依赖原则

yilai4.png

再举个例子

可能刚才上面的例子有点玄乎。举一个具体的业务例子,有这么一个业务场景,有一个时间组件,业务说要缓存用户选择的时间,直到页面关闭,例如说他选了上周六,那他去到其他页面也要是上周六而不能是该页面的默认的昨天,因为很明显用户现在关心的是上周六的数据。OK,很明显,这个需求有两个步骤:

  1. 在用户选择了时间后,缓存当前用户选择的到sessionStorage里

    const onChange = (time: Dayjs) => {
      ......
      sessionStorage.setItem('time', time);
    }
    
  2. 在每个页面初始化的时候,看一下当前的sessionStorage里有没有缓存的时间,有的话有这个

    const [time, setTime] = useState(dayjs().add(-1));
    ...
    useEffect(() => {
      if (sessionStorage.getItem('time')) {
        setTime(sessionStorage.getItem('time'))
      }
    }, [])
    

这段代码初初看起来是没啥问题的(应该),然后它上线了。但是过两天业务说,用户集体反馈说缓存的功能很好用,就是缓存能不能不要消失,不然每次重新打开页面都要设置很多选项。事实上,缓存的意义正在于此,就是为了避免每次都要点很多重复的步骤,所以这个改动也无可厚非嘛。 所以很明显,我们需要改成localStorage,然后这时候你发现,整个系统所有的缓存都用的是sessionStorage,而他们都写在了每一个业务代码的深处。你想着说,我直接全局替换不就成了,搜了一下发现几百个改动,你有点害怕,但是又安慰自己没事,我只是换个localStorage,然后commit了,提了MR。要搁以前,你的组长你的项目负责人可能看都不看就给你合进去了,但是这时候不行了,公司说谁合代码谁要负责了。所以他这时候你的组长不开心了,开始问你,你这几百个change,是什么?你不是就改个localStorage吗,怎么这么多change,你有一个一个地看一下嘛?你这改动太大了,测试知道嘛,有一个个地测嘛?这时候开始陷入了两难的境地。。。

所以回到最初的场景里,我们要如何才能保证现在的我们改动上,只有一两行代码呢?参考上面刚才的例子,这时候,我们就需要一个抽象模块。很明显,sessionStorage和localStorage共同的本质性的特征是存储和读取,所以我们需要抽象一个Storage对象

class Store {
  private storage: Storage;
  constructor() {
    this.storage = sessionStorage;
  }
  public get(key: string) {
    return this.storage.getItem(key);
  }
​
  public set(key: string, value: unknown) {
    this.storage.setItem(key, JSON.stringify(value));
  }
}
export default new Store();

这时候实现的步骤就变成了

  1. 在用户选择了时间后,缓存当前用户选择的到sessionStorage里

    const onChange = (time: Dayjs) => {
      ......
      Store.set('time', time);
    }
    
  2. 在每个页面初始化的时候,看一下当前的sessionStorage里有没有缓存的时间,有的话有这个

    const [time, setTime] = useState(dayjs().add(-1));
    ...
    useEffect(() => {
      if (Store.get('time')) {
        setTime(Store.get('time'))
      }
    }, [])
    

这时候业务再和你说需要改成localStorage的时候,你就只需改动一行代码

class Store {
  。。。
  constructor() {
    // this.storage = sessionStorage;
    this.storage = localStorage;
  }
  。。。
}
export default new Store();

这时候,你改起来手不抖,心不慌了。你提的MR,由于改动只有一行,组长又可以看都不看就给你合进去了。(假的

依赖反转原则

这时候可能会说,虽然你这样只改了一行代码,但是这个模块本身被引用了几百次,那这样和直接在全局改有什么区别?当然是有的。这时候我们就需要分析一下依赖关系了。

chouxiang1.png

可以从图中看出,这样的依赖关系就是经典的依赖反转原则的。所谓的依赖反转原则就是指,高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节 (details)依赖抽象(abstractions)。

这里有两个点需要我们去理解:

  1. 为什么我们应该依赖抽象而不是依赖具体实现?

    设想一下,我们每次修改抽象接口的时候,一定会需要修改具体的实现。但反过来,修改具体实现时,却很少需要求修改相应的抽象接口,所以我们可以认为抽象接口比实现更加的稳定。

  2. 什么叫具体实现细节 (details)依赖抽象(abstractions)?

    回顾刚才,Store的设计,基于我们的业务场景出发,就是两个功能,读取和存储。而落实到具体的实现的时候,要用sessionStorage还是localStorage,亦或者用IndexDB,则依赖于Store的设计,去满足于Store的设计。

最后,可以从图里看到,这个依赖关系是符合我们上面讲到的稳定依赖原则的。对于所有引用这个Store模块的来所有业务模块来说,Store是抽象层,是稳定的。所以不管我们的Store模块用的是什么存储,只要它对外提供的存储和读取功能没变,那么依赖它的模块就不需要关心Store具体实现的变化。

所以在这个章节,我们可以看到,在我们面向过程编程中,需要进行模块的抽象,而对此需要的是考虑哪些部分,它的稳定性是不一样的,它是一个单独的部分,而在上诉的例子中,我相信大部分人都知道SessionStorage、LocalStorage、IndexDB这些前端存储的API,而它们是有可能被相互换来换去的,所以最好就设计成抽象的,让其他模块去依赖这个抽象。

依赖注入的思想

而在实际操作中,依赖注入是一个比较常用到的符合该原则的设计模式。

依赖注入也是一种常见的,可以优化模块依赖关系的方法,对于项目的可维护性也有着较为明显的作用。在百度百科中,是这样解释的,所谓依赖注入,是指程序运行过程中,如果需要调用另一个对象协助时,无须在代码中创建被调用者,而是依赖于外部的注入。因为依赖注入狭义上是实现控制反转的方法之一,而对于控制反转在此不做讨论,这个是在Spring中被谈到的一个概念。 抛开对象而言,用一个更加广义的角度来说,也就是换言之,所谓依赖注入,是指程序运行过程中,如果需要调用另一个模块时,不需要去具体实现该模块,而是依赖于外部该模块的注入。

待补充。。。

TS约束

TS对于提高项目的可维护性的重要性不言而喻。在这里,主要是想分享一下Record的用法对于提示项目的可维护性的好处。

说到Record大家肯定不陌生,它是TS内置的一个高级类型。可能它最基础的用法就是用来定义一个什么都没有的对象了。

type Record<T extends string, U> = {
  [K in T]: U
}
​
const obj: Record<string, unknown> = {}; // 定义一个普通的对象

但实际上它更应该的用法是用于约束key的类型。 在项目中,我们经常有很多的配置需要进行配置,这时候我们就可以借助Record来约束我们的配置选项了。

// TS报错: Property 'b' is missing in type '{ a: number; }' but required in type 'Record<"a" | "b", number>'.ts(2741)
export const config: Record<'a' | 'b', number> = {
  a: 233,
};

从这个例子中我们可以看到,如果我们粗心地漏配置了一个选项,TS 会报错告诉我们。而从这个层面延伸出来的是,如果后续我们修改了Key的类型,那么无论是新增还是删除,TS都会提示我们这里出错了。 在这里举一个比较实际的例子,假设你需要配置一个工作日的起床时间,代码如下。

enum WeekDay {
  Monday = 'Monday',
  Tuesday = 'Tuesday',
  Wednesday = 'Wednesday',
  Thursday = 'Thursday',
  Friday = 'Friday',
}
​
const WakeupTime: Record<WeekDay, string> = {
  [WeekDay.Monday]: '09:00',
  [WeekDay.Tuesday]: '09:00',
  [WeekDay.Wednesday]: '09:00',
  [WeekDay.Thursday]: '09:00',
  [WeekDay.Friday]: '09:00',
};

假设此时,你的老板通知你,你周六也需要上班了,那么你只需要在WeekDay中添加一个 Saturday, TS自然会提示你WakeupTime里的配置出错了,你需要进行修改。可能这样的例子有点简单,但事实上当你将所有相关的配置信息都这样约束后,对项目的可维护性会有很大的帮助~

其实这里还有一个好处,如果你有一个核心但是多变的业务逻辑,可以利用这样的一个约束关系去维护相关的逻辑间的关系。

我们可以举一个关于地区的改动的例子,来感受一下~

在项目的初期对于一些特殊逻辑,例如说只有北京和上海地区可以多一个小时交易时间, 我们会直接写

const specialRegions = [RegionsEnum.BJ, RegionsEnum.SH];
if (specialRegions.include(region)) {
  // ...
}

前面我们说过,地区是一个很核心的业务逻辑,有许许多多的业务逻辑围绕着他,但是它又是不稳定的,如果你们业务扩张的快, 那么地区就会一直新增。那么如何地维护好这些依赖关系,最直观的需求时,当我改动到地区的时候,所有相关的逻辑我都会关注到,那么Record就是一个很好的帮助。所以在后期我们都将它改成这样的

const specialRegionsMap:Record<RegionsEnum, boolean> = {
  [RegionsEnum.BJ]: true,
  [RegionsEnum.SH]: true,
  // ...
}
if (specialRegionsMap(region)) {
  // ...
}

这样后续如果有新增地区时,都会让我们关注到这里的逻辑变动~

依赖倒置原则+TS Record

在这里我们重新来看依赖反转原则。这个原则主要是讲述我们应该依赖抽象而非具体实现,让具体实现来依赖抽象。而在前端的项目中,TS的定义其实也是一种抽象,所以我们也可以依赖TS的定义,而具体实现去依赖TS的定义。这句话可能有点绕,让我们来具体分析。

首先是,TS的定义其实也是一种抽象。我们一直在讲抽象,抽象,那抽象到底是什么呢?百度百科是这样解释的,抽象是从众多的事物中抽取出共同的、本质性的特征,而舍弃其非本质的特征的过程。而在项目的维护性上的层面来说,抽象是将变动、变化、不稳定的需求抽取出它的本质。 那么我们的TS定义,也是符合这样的描述,例如我们上面的例子中的特殊逻辑,虽然需求表面上是北京和上海地区需要做一个特殊逻辑,但需求的本质是,在当前的所有地区中北京和上海地区符合这个特殊逻辑,其他地区不符合,那我们的TS定义,就是在描述这个需求的本质,每个地区是否符合这个特殊逻辑。所以,TS的定义其实也是一种抽象。

type a = Record<RegionsEnum, boolean>;

其次是具体实现去依赖TS的定义。我想这句话是比较容易理解的。还是看刚才的那个特殊逻辑的例子,可以看到我们的 specialRegionsMap 是需要依赖于 TS定义去实现的,具体哪些地区是true,哪些地区是false,都是具体实现的部分了

这时候,我们再来看一下依赖关系

未命名文件 (5).png

可以看到我们这样的关系是符合我们的依赖倒置原则的。

可能有的同学会有问了,你这个是抽象了不同的内容,那如果这些以后都不会变了,变的是其他内容呢?那我想,如果变化的内容是其他维度的,那么是我们抽象的不好,应该去针对变化的内容进行抽象。

总结

所谓项目的可维护性,是指在以后,进行需求变更时如何付出尽可能小的代价和改动去满足需求的变更。而在这里,主要是给大家分享了从模块的依赖关系入手去提高项目的可维护性,和一个具体的利用TS定义去实际提高项目的可维护性的方法。

参考文章:

  1. 为什么有很多人说 Go 语言不需要依赖注入?
  2. 依赖倒置原则(Dependency Inversion Principle )
  3. 理解面向过程(OPP)、面向对象(OOP)、面向切面(AOP)
  4. 《架构整洁之道》