本章节我们先讲一讲在软件设计中,模块化的一些设计和复用原则,然后再介绍 GoFrame
框架的模块化设计,以便于大家更好地了解 GoFrame
框架模块化设计的思想。
一、什么是模块
模块 也称作 组件,是软件系统中可复用的功能逻辑封装单位。在不同的软件架构层次,模块的概念会有些不太一样。在开发框架层面,模块是某一类功能逻辑的最小封装单位。在 Golang
代码层面中,我们也可以将 package
称作模块。
二、模块化的目标
软件进行模块化设计的目的,是为了使得软件功能逻辑尽可能的 解耦 和 复用,终极目标也是为了保证软件开发维护的效率和质量。
三、模块复用原则
REP
复用/发布等同原则
复用/发布等同原则( Reuse/Release Equivalency Principle
):软件复用的最小粒度应等同于其发布的最小粒度。
直白地说,就是要复用一段代码就把它抽成模块。
CCP
共同闭包原则
共同闭包原则( Common Closure Principle
):为了相同目的而同时修改的类,应该放在同一个模块中。
对大部分应用程序而言, 可维护性 的重要性远远大于 可复用性,由同一个原因引起的代码修改,最好在同一个模块中,如果分散在多个模块中,那么开发、提交、部署的成本都会上升。
CRP
共同复用原则
共同复用原则( Common Reuse Principle
):不要强迫一个模块依赖它不需要的东西。
相信你一定有这种经历,集成了模块A,但模块A依赖了模块B、C。即使模块B、C 你完全用不到,也不得不集成进来。这是因为你只用到了模块A的部分能力,模块A中额外的能力带来了额外的依赖。如果遵循共同复用原则,你需要把A 拆分,只保留你要用的部分。
复用原则竞争关系
REP
、 CCP
、 CRP
三个原则之间存在 彼此竞争 的关系。 REP
和 CCP
是 黏合性原则,它们会让模块变得更大,而 CRP
原则是 排除性原则,它会让模块变小。遵守 REP
、 CCP
而忽略 CRP
,就会依赖了太多没有用到的模块和类,而这些模块或类的变动会导致你自己的模块进行太多不必要的发布;遵守 REP
、 CRP
而忽略 CCP
,因为模块拆分的太细了,一个需求变更可能要改 n
个模块,带来的成本也是巨大的。
图2. 模块复用原则竞争关系张力图
优秀的架构师应该能在上述三角形张力区域中定位一个最适合目前研发团队状态的位置,例如在项目早期, CCP
比 REP
更重要,随着项目的发展,这个最合适的位置也要不停调整。
四、框架模块设计
经过前面关于模块设计原则和复用原则的介绍,我们应该对模块开发和管理这块的原则有了大概的了解,那么我们接着介绍框架的模块化设计就比较容易理解了。
单仓库包设计
根据 REP
原则我们了解到,一个可复用的模块是支持独立版本管理的,单仓库包设计也正是如此。 Golang
中很多这样的单仓库包,一个包就是一个独立的模块。单仓库包根据 CRP
原则可以再进一步的细化解耦拆分。我们来举个例子,在开发复杂的业务项目场景下,常见的包依赖情况,类似于这样的:
module business
go 1.16
require (
business.com/golang/strings v1.0.0
business.com/golang/config v1.15.0
business.com/golang/container v1.1.0
business.com/golang/encoding v1.2.0
business.com/golang/files v1.2.1
business.com/golang/cache v1.7.3
business.com/framework/utils v1.30.1
github.com/pkg/errors v0.9.0
github.com/goorm/orm v1.2.1
github.com/goredis/redis v1.7.4
github.com/gokafka/kafka v0.1.0
github.com/gometrics/metrics v0.3.5
github.com/gotracing/tracing v0.8.2
github.com/gohttp/http v1.18.1
github.com/google/grpc v1.16.1
github.com/smith/env v1.0.2
github.com/htbj/command v1.1.1
github.com/kmlevel1/pool v1.1.4
github.com/anolog/logging v1.16.2
github.com/bgses123/session v1.5.1
github.com/gomytmp/template v1.3.4
github.com/govalidation/validate v1.19.2
github.com/yetme1/goi18n v0.10.0
github.com/convman/convert v1.20.0
github.com/google/uuid v1.1.2
// ...
)
示例中的模块依赖,都是一些通用模块,大部分业务项目都会涉及到。模块地址是便于演示而写的随意地址,并不一定真实存在。
使用 Golang
开发过复杂一点的业务项目的小伙伴们,对于这样的场景大家一定不会陌生。一个正常的软件企业,往往至少有数百个这样的项目,真实的模块依赖关系比这里的例子更加复杂。在 Golang
项目开发中,对于模块依赖的维护性挑战是比较大的,我们往往会遇到一些痛点,主要的几点:
- 实现相同功能逻辑的模块较多,选择成本增加
- 项目依赖的模块过多,项目整体的稳定性会受到影响
- 项目依赖的模块过多,项目无从下手是否应当升级这些模块版本
- 模块分散设计,不成体系,难以统一。具体请参考章节: 统一框架设计
现身说法举例。
本厂的自研模块有数十个,这些模块已经被频繁使用遍布到数百个业务项目中。有一次,我们提交了对几个模块的 bug fix
,其中有两个还是比较重要的 bug
,紧接着,我们要求所有业务项目全部升级一下对应模块的版本号,并且这些版本号填写得务必小心。当然,这肯定也不是唯一的一次,随后相同的场景各位同学可以自行脑补。
我们也可以选择,不去主动推进所有业务项目升级模块,只要项目还没有触发这些 bug
,那么就等着业务项目踩到了坑再由项目组自行去升级。领导如果听到这种解决方案......各位同学再自行脑补一下和谐的场景。
其实这种问题主要的原因,还是来源于模块的不稳定,模块也是需要不停迭代改进的。项目使用到这些模块,那么就与这些模块建立了 耦合关系,耦合模块的变化,必然会影响到依赖的相关项目。越底层的基础模块,顶层模块则对其依赖的越多,影响面也就越大。那是不是只要模块稳定了,就不会存在这样的问题了呢?风险依旧是存在的。 Golang
标准库大家觉得算稳定吧,但是它也是在不断的迭代改进过程中,也是不断有 bug
出现,只是大家幸运没踩上去而已,风险相对较低。
好的软件设计,并不是一成不变,而是能够做到快速响应变化,根据变化快速改进完善。模块的设计和管理,亦是如此。寻求能够快速改进模块逻辑、有效维护模块依赖的方案,比编写更加稳定的功能模块,更加高效和务实。
模块聚合设计
GoFrame
的模块化管理思想更偏重于 CCP
原则,看重 可维护性 比 可复用性 更多。由于 GoFrame
是基于 开发框架 层面的出发点考虑,因此整体框架的设计不是单点设计的,而是自顶向下设计的。前面有提到,越底层的基础模块,顶层模块则对其依赖的越多,影响面也就越大。因此,框架将一些 通用性的核心模块 进行统一维护,这样做的目的是使得这些模块共同形成闭包,保证基础模块的稳定性,并通过统一的版本管理,提高开发效率和可维护性,降低接入和维护成本。
站在 GoFrame
框架模块化设计的角度,前面例 子中的依赖情况应当变成以下的样子:
module business
go 1.16
require (
github.com/gogf/gf v1.16.0
github.com/goorm/orm v1.15.1
github.com/goredis/redis v1.7.4
github.com/gokafka/kafka v0.1.0
github.com/google/grpc v1.16.1
// ...
)
GoFrame
只维护一些通用性的核心模块,其他非通用核心模块或者稳定性较高的模块,依旧建议使用单仓库包的形式进行依赖引入,正如 REP
和 CRP
模块复用原则倡导的那样。在这种设计模式下:
- 框架核心维护较全面的通用基础模块,降低基础模块选择成本
- 我们只需要维护一个统一的框架版本,而不是数十个模块版本
- 我们只需要了解一个框架的内容变化,而不是数十个模块的内容变化
- 升级的时候只需要升级一个框架版本,而不是数十个模块版本的升级
- 减轻开发人员的心智负担,提高模块可维护性,更容易保证各业务项目的模块版本一致性