GoFrame 工程开发设计-对象封装设计
一、Golang包设计
Golang开发语言并没有完整实现OOP
特性,因此我们只能采用包封装的方式来践行"高内聚,低耦合"的功能设计。在进行代码分层管理之后,我们会发现包命名变得很困难。
大部分时候,我们都习惯按照业务领域来进行命名。例如,在api/dao/service
分层下,我们可能都会同时存在一个以user
命名的包名(表示用户相关的功能逻辑),由于包名相同,在使用的时候却有极大的困扰。主要的两个痛点:
- 对于复杂业务模块包名命名困难,例如:业务模块为
internal_member_privilege
的包名 - 管理过多重复的包名工程效率太低,为了解决重复包名往往会创建很多引用别名(
import alias
) - 工程管理上容易引起包的循环引用(
cycle import
)问题
需要注意的是,Golang语言层面的包循环依赖检测其实是很棒的一个特性,它以package
作为代码封装基本单位,使得程序逻辑在package
之间的执行路径都是单向调用链,可以帮助你梳理出清晰的package
依赖关系、编写出更加健壮性的代码。
GoFrame
开发框架经过大量的项目工程实践,本着从简约、简洁、高效、易维护的设计理念出发,总结出了一些关于包设计和命名约束的最佳实践,可供参考。
二、对象封装设计
在代码分层设计之后,我们尽量地减少封装包的数量、降低包的复杂度,尽可能采用结构化对象的方式来封装代码处理逻辑。
对于业务项目而言,业务的复杂度会不断/快速增长,我们期望设计的模块复杂度尽可能的小、职责尽可能的单一。而直接使用包封装设计会使得每个包管理的资源比较多、单个包复杂度会比较高、并且存在过多同名包问题。因此我们需要将代码做分层设计(划分职责)、将包内容做进一步拆分(细化粒度),并将代码模块的粒度细化为了"对象"方式进行封装(这里的"模块"从package
细化为了object
)。目的是使得整体模块设计更加的解耦,能够快速响应业务发展的变化。对于业务项目而言,我们采用对象封装设计后,将会失去包循环依赖检测特性带来的好处,转而由开发者自行维护对象之间的依赖关系。
1、业务包命名约束
涉及到业务对象封装的代码层级主要为controller/service
。每个业务包仅对外暴露实例化的对象用于该业务领域的具体功能逻辑封装,同一层级下不同的业务领域逻辑通过不同文件来分别管理。包对外的公开对象采用业务模块名称(大驼峰)来命名,包内部的数据结构命名采用分层名称(缩写)+业务模块名称(大驼峰)来命名。
代码分层 | 分层名称缩写 | 数据结构命名示例 |
controller | c | cUser |
service | s | sUser |
特别需要强调的是,在controller/service
层级中的代码,有且仅有需要导出的实例化对象才能公开。并且由于同一包下包含多个业务领域的数据结构定义,因此在命名的时候务必遵从命名约束,否则容易出现命名冲突。采用单包管理以及实例化对象引用的设计,整个包对外引用简洁清晰、内部维护紧凑简便、规避循环引用问题。
2、controller层对象封装
封装示例:
使用示例:
3、service层对象封装
封装示例:
使用示例:
4、dao层对象封装
dao
层的对象封装是通过框架开发工具维护的,开发者无需自己定义。
使用示例:
5、对象封装安全
如果各分层中的封装对象都是以" 可变变量 "的形式对外暴露使用,因此存在被修改的安全风险。因此大家注意这些公开的对象不要以指针(也尽量不要以接口实例)方式公开这些对象、不要设置公开属性(建议通过公开方法暴露内部属性)。
以下是一个错误的示例:
以下是一个正确的示例:
三、接口化封装设计
富有实战经验的您一定发现了,我们上面推崇使用具体化的对象来封装业务逻辑,而没有一丁点的接口设计的影子。是的,本着务实以及成本收益的衡量,我们确实推崇使用具体化的对象来封装业务逻辑,特别是针对于controller
层以及dao
层代码。对于service
层的代码,亦是如此。本章节主要侧重于service
层代码的接口化封装设计介绍。
1、对象封装的痛点
GoFrame
工程设计的显著的一个特点是,按照技术维度进行代码分层、按照业务维度进行对象封装。这种设计思想落地的特点就是在同一代码分层,业务模块是平级的,使得各个业务模块之间隔离性降低了,这也是纯粹使用对象化封装的痛点。比如以下的示例:
可以看到在service
层下,网络模块(Network
)访问资源模块(Resource
)时,可以直接访问到其所有的内容,无论是公开还是私有方法,私有的属性也能访问甚至修改。同时,杂乱的资源暴露也会使得调用者感觉困惑,模块管理和协作成本比较高。我们更好地隔离资源,方便内部模块之间管理和协作,这种场景下我们推荐对service
业务模块使用接口化设计。
2、service层接口化设计
将一个项目的业务模块采用对象封装其实也是有利弊的,不过针对大多数的项目,都不会接触到需要挑战GoFrame
框架工程化设计的边界。在GoFrame
的整个分层设计中,service
层的业务逻辑沉淀是最多的,也是最复杂的一部分,在这一部分采用接口化设计是最有价值的,收益成本比很高。因为service
层的模块不仅对外部开放,在service
层内部,模块与模块之间的调用,才是整个业务项目中交互最频繁、设计最复杂的。降低这一层的维护成本,简化层级内部模块之间的调用复杂度,是最有价值和收益的事情。例如上面的例子,我们看看如何来接口化改进。
- 原本的对象封装
- 采用接口化封装
接口化只需要两步即可:
- 增加业务模块的接口定义
- 将原本对象方法返回的对象指针改为返回接口
3、对象封装到接口切换
大家也应该注意到了,对于service
层的对象化封装方式,有一些不一样。在service
层我们使用了方法返回对象,而不是采用对象变量形式。为什么呢?回答这个问题之前,我们先来看看将资源模块(Resource
)进行接口化改进之后,我们在原来的网络模块(Network
)中是如何使用的:
是的,目标业务模块采用接口化改进之后,对于调用方来讲,使用方式并没有发生变化。这便是我们为什么要在service
中使用方法化封装对象,而不是直接使用对象变量定义的原因:
- 减少业务模块接口化改进引起的改动,降低维护成本
- 支持从对象封装调用无缝切换到接口调用,调用端无感知
4、接口化设计的成本
可以看到,我们增加一层接口定义,其实也增加了一层代码的维护成本。此外,接口化的代码对于代码调试和定位不太友好,特别是多个层级嵌套的接口而言,想要定位到具体的实现成本较大。对于大部分的项目而言,其实往往不太需要接口化设计。架构设计中常常出现的悲伤故事之一,就是为了设计而设计。
因此,始终本着务实的设计原则,在GoFrame
工程化设计中,我们推荐项目初期对业务逻辑的封装使用对象封装的方式,并支持项目根据业务发展需要从对象封装设计到接口化设计的无缝切换。同时,我们建议接口化设计中,不要使用多层接口嵌套(即service
接口方法直接返回实例化对象而不是另一个接口,接口又是接口,以此类推)。
四、注意事项
- 由于对象封装业务模块的粒度比较细,需要由开发者来维护业务模块之间的循环依赖关系。
- 对于复杂的单仓业务项目(没有意愿采用服务化拆分),可以考虑前期即对
service
层采用接口化设计。 - 在
service
目录下的每个业务模块不要都强行放一个代码文件,可以按照多个文件拆分管理,文件随着业务复杂度的提高可能文件会比较多,属于正常现象。 - 如果业务模块本身是极其低耦的,并且模块逻辑较复杂,可以考虑将其挪到
service/internal
下使用包管理,并在service
下保留关联的接口方法即可。service/internal
目录存在的意义之一便是为此准备的。