阅读(986) (15)

GoFrame 框架设计-模块化设计

2022-03-26 14:38:41 更新

本章节我们先讲一讲在软件设计中,模块化的一些设计和复用原则,然后再介绍​GoFrame​框架的模块化设计,以便于大家更好地了解​GoFrame​框架模块化设计的思想。

一、什么是模块

模块也称作组件,是软件系统中可复用的功能逻辑封装单位。在不同的软件架构层次,模块的概念会有些不太一样。在开发框架层面,模块是某一类功能逻辑的最小封装单位。在Golang代码层面中,我们也可以将package称作模块。

二、模块化的目标

软件进行模块化设计的目的,是为了使得软件功能逻辑尽可能的解耦和复用,终极目标也是为了保证软件开发维护的效率和质量。

三、模块复用原则

  • REP复用/发布等同原则

复用/发布等同原则(​Release/Reuse 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个模块,带来的成本也是巨大的。

2652711029-5d007c0c1f843_articlex

优秀的架构师应该能在上述三角形张力区域中定位一个最适合目前研发团队状态的位置,例如在项目早期,​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​模块复用原则倡导的那样。在这种设计模式下:

  • 框架核心维护较全面的通用基础模块,降低基础模块选择成本
  • 我们只需要维护一个统一的框架版本,而不是数十个模块版本
  • 我们只需要了解一个框架的内容变化,而不是数十个模块的内容变化
  • 升级的时候只需要升级一个框架版本,而不是数十个模块版本的升级
  • 减轻开发人员的心智负担,提高模块可维护性,更容易保证各业务项目的模块版本一致性

五、常见问题解答

虽然每一个模块都按照低耦合设计,模块可以选择性引入,但在使用时也得全量下载完整框架代码

文件层面的源文件下载与模块之间的逻辑耦合没有直接关系。需要注意的是,编译型语言和解释型语言的模块管理逻辑不太一样。

image2021-3-25_17-10-44

  • 编译型语言:(以静态编译为例)往往以​main​包为入口,编译器会自动分析源码并将所有逻辑依赖模块中对应的资源进行编译处理,最终生成为静态二进制文件进行发布,自身源文件以及依赖模块(逻辑依赖)的源文件只在编译阶段使用,源码文件并不会直接用于发布,如:C/C++、Golang、Rust等。
  • 解释型语言:往往会将自身源文件(或中间码)以及依赖模块的源文件(或中间码)全部进行打包发布,例如:PHP、Java、NodeJS、Python等。这个时候,依赖模块的源码大小对于项目发布来说影响会比较大。并且,打包时候的模块依赖处理并不会检查"逻辑依赖",只要依赖配置文件中存在指定模块,那么该模块都会被共同打包发布。假如模块中有10万个函数,即使其中只有一个函数被使用到,该模块所有函数将被共同打包发布。因为解释型语言在代码发布前并没有"编译-汇编-链接"等阶段,只能在运行时对源码及模块依赖做完整解析处理。特别是PHP/Java转Go的同学,这一块的思维需要转变适应。

框架中任一模块的版本变更都会引起框架版本的发布,框架的发布频次是否会变高

最主要的一点,框架的模块设计也会充分考虑稳定性因素,仅会将一些通用性的核心模块按照​CCP​进行管理,并不会包含特定业务的逻辑封装,因为涉及到特定业务的功能逻辑实现将会为框架模块带来更多的不稳定变化。

在保证一定的稳定性前提下,模块的版本发布按照框架统一的迭代开发计划进行,除了必要的​hot fix​之外,版本发布设置有固定的时间窗口,以保证框架核心的稳定性。因此,框架通过模块聚合的方式进行版本管理,不仅没有增加框架的版本发布频次,反而降低了框架的版本发布频次,使得框架中的模块版本更加稳定。

框架聚合并维护通用性的核心模块,通用性的核心模块定义是什么

首先,它们是基础模块,往往位于模块依赖链的最底层,这部分的模块变化对项目稳定性影响最大。

其次,绝大部分项目(二八定律来讲为80%以上)都会依赖的通用性基础模块,可以称作核心模块。

最后,这部分模块不包含具体业务的封装实现。例如:微信公众号/小程序、CMS/CRM、区块链等相关模块都是具体业务实现封装。

关于模块通用性的评估无法完全准确,框架为保证核心精简会尽可能持保守态度,并且会根据实际情况在未来的迭代中逐步做调整。

以下为可供参考的模块分层:

image2021-3-8_22-51-45

  • 业务实现模块:特定业务项目逻辑实现,这里包含业务项目进一步的代码分层。
  • 通用业务模块:可复用的业务逻辑封装,例如微信公众号/小程序、CMS/CRM、区块链等相关业务逻辑封装模块。
  • 通用基础模块:标准库不提供或者基于标准库封装扩展的基础模块,例如:配置、校验、缓存、ORM、I18N等等。
  • 标准基础模块:Golang标准库。