浅谈模块化设计

1 模块与模块化设计

1.1 什么是模块?

广义来说,模块就是“一个软件块”,下至函数、类,上至服务、应用,都可称之为模块。但这样的说法未免过于笼统,本文讨论的模块是狭义的,定义如下:

软件模块是可部署的、可管理的、原生可重用的、可组合的、无状态的软件单元,它为用户提供了简洁的接口。

  • 可部署:模块是一个独立部署单元(因此区别于类和包)。
  • 可管理:模块是一个可管理的单元。模块可以单独安装、卸载以及更新。一旦规划好边界,不同的模块可以并行开发。
  • 可测试:模块是一个测试单元,可以独立测试。
  • 原生可重用:模块是进程内部的可重用单元,其操作是通过直接调用方法触发的。
  • 可组合:模块是可组合的单元。模块可以由其他模块组成。
  • 无状态:模块是无状态的,我们不会实例化软件模块(但需要实例化模块中的类,类是有状态的)。

模块的概念,比“服务”小一级,比“类”大一级。

更具体一些,在 Java 中,模块化单元就是 JAR 文件

1.2 什么是模块化设计?

粗浅理解:

  1. 针对模块、包的设计,就是模块化设计
  2. 以模块为基本元素来设计系统,就是模块化设计

1.3 为什么需要模块化设计?

1.3.1 分离复杂度

要编写复杂软件又不至于一败涂地的唯一方法,就是用定义清晰的接口把若干简单模块组合起来,如此一来,多数问题只会出现在局部,那么还有希望对局部进行改进或优化,而不至于牵动全身。——《UNIX 编程艺术》

当我们要构建一个复杂软件系统时,仅有 OO 设计模式是不够的。因为设计模式工作在类级别,过于琐碎和具体。设想一个项目,包含上千个类,相互之间耦合严重,依赖关系错综复杂,很难理解和维护;假如把这些类分组,封装到十几个不同的模块中,定义好每个模块的接口和依赖关系,这个项目会清晰很多。也就更容易维护和扩展。

1.3.2 辅助沟通,对齐理解

架构师更关注应用和服务,开发人员更关注具体的代码,中间有一个空白地带——模块和包。

架构师和开发人员,都应主动关注,填补空白。

2 模块化设计思路

总的来说,把系统划分为一个个相互协作的模块,尽可能做到“低耦合高内聚”。

2.1 模块划分原则

除了 1.1 中给出的模块定义之外,还有一些指导原则:

  • 大小适宜:封装出来的模块,不能过大,也不能过小(详见 2.2.1 和 3.1 关于“粒度”的讨论);
  • 紧凑性:就是一个设计是否能装进人脑中的特性。与“内聚”的概念类似。符合用户直觉,没有多余的东西;
  • 正交性:每一个动作只改变一件事,不会影响其他。改变每个属性的方法有且只有一个。例如,显示器就是正交控制的。你可以独立改变亮度而不影响对比度;
  • 单点性:即 DRY 原则(或称 SPOT 原则——Single Point of Truth,真理的单点性)。任何一个知识点在系统内都应当有一个唯一、明确、权威的表述。尽可能复用,不要重新发明轮子。

2.2 分层架构

书上有很多模块级别的模式,可以去查阅,这里不展开。主要讲一下实战中用得最多的架构模式——分层。

2.2.1 粒度与层次

粒度指的是一个系统要拆分成的各个部分的范围。

  • 粗粒度的实体做得更多,行为上更丰富;细粒度的实体做得更少,具备更高的可重用性。
  • 粗粒度的实体更加抽象,更靠近用户;细粒度的实体更加具体,更靠近机器。

很自然地产生一种层次关系:粗粒度的实体(策略)在上层,贴合用户需求;细粒度的实体(机制)在底层,驱动硬件设备。

这两种层次截然不同,无法交流,因此中间还需要一个“胶合层”。

例如:源代码->编译器->机器码,就是这样一种三层架构。

后端服务中有:Controller -> Service -> DAO,也是三层架构。

下面是一个典型的架构分层图:

2.2.2 模块与层次——全景图

一方面,层次本身就是一种广义的模块;另一方面,把模块嵌入各个层次,更容易定义其职责和依赖关系。

以下是全景图:

2.2.3 前后端分离与层次

这个值得单独提一下。我在帆软的时候,一直对前后端分离感兴趣,好奇它是怎么实践的,到底有什么好处;后来在依图,用前后端分离的方式做了快两年的开发,深刻体会到,它确实是个好东西。

前后端分离,是一种模块化设计思路,是一种层次架构。系统粗分为前端模块、后端模块;或称之为前端展现层、后端服务层。

前端实现策略,后端实现机制。策略变化相对快,机制变化相对慢,在接口固定的情况下,两边可以独立变化,互不干扰。

3 关键设计权衡

3.1 模块粒度:可用/重用悖论

最大化重用会使得可用复杂化。

可重用的模块必须是灵活的,而随着灵活性的增加,会对应着复杂性的增加。同样地,增加模块使用、管理以及部署的便利性会减少模块的可重用性。

启示

一味追求灵活性、“松耦合”(细粒度),是不对的;一味追求便利性(粗粒度),也是不对的。

需要根据实际情况做出取舍。

常用解决方案

先有一堆细粒度的模块,作为灵活方案;然后再将它们组织成一个粗粒度的模块,作为便利方案。

例如,软件配置文件中,尽量多提供选项(细粒度、灵活性高),同时,又提供一份默认配置,开箱即用(粗粒度)。就像智能手机,无需配置,开机即用;当你真的需要某个冷门功能时,又可以去设置里面把选项找出来。

3.2 灵活性:好钢用在刀刃

为了尽量做到“低耦合高内聚”,OO 设计中提供了 SOLID 原则

灵活性虽然提高了,但是由于额外抽象层的引入,系统复杂性也相应增加。如果盲目使用,增加的复杂性实际上会降低我们维护和扩展软件的能力。(Linux 坚持使用宏内核的紧耦合方案,亦有其道理)

那么,最适合使用 SOLID 原则的地方在哪里?系统中的结合点,也就是两个模块边界上的连接点。这里需要最大的灵活性和弹性。

3.3 薄胶合层:勿滥用抽象

所谓的胶合层,就是整合上层策略(应用逻辑)与底层机制(域元语级)的中间代码。

如果过于强调抽象,很可能写出大量的中间代码,使得胶合层越来越厚,越来越复杂。这样做可能弊大于利。

按照 UNIX 传统,胶合层越薄越好。如果有许多代码既不属于策略,又不属于机制,很可能除了增加系统复杂度之外,毫无用处。

(例如职场上某些人,既不做上层规划和决策,又不做底层执行,只是在中间传话(就像胶合层)。在扁平化的公司里,生存空间是极小的)


ref:

  • 《UNIX 编程艺术》
  • 《Java 应用架构设计——模块化模式与 OSGi》
  • 耦合与内聚