Skip to end of metadata
Go to start of metadata
本文的对象封装建立在良好的代码分层设计之上,在开始了解 GoFrame 的对象封装设计之前,建议先充分了解一下《 代码分层设计 》。

一、Golang包设计

Golang开发语言并没有完整实现OOP特性,因此我们只能采用包封装的方式来践行"高内聚,低耦合"的功能设计。在进行代码分层管理之后,我们会发现包命名变得很困难。大部分时候,我们都习惯按照业务领域来进行命名,例如,在api/dao/service分层下,我们可能都会同时存在一个以user命名的包名(表示用户相关的功能逻辑),虽然我们可以通过不同的路径在import的时候进行区分(这也是《Effective Go》推荐的管理方式,允许同名包名称存在并通过import不同路径来区分不同职责的相同包名),但是由于包名相同,在使用的时候却有极大的困扰。一个主要痛点是管理过多重复的包名工程效率太低,另一个是在工程管理上容易引起包的循环引用(cycle import)问题。

需要注意的是,Golang语言层面的包循环依赖检测其实是很棒的一个特性,它以package作为代码封装基本单位,使得程序逻辑在package之间的执行路径都是单向调用链,可以帮助你梳理出清晰的package依赖关系、编写出更加健壮性的代码。

对于业务项目而言,业务的复杂度会不断/快速增长,我们期望设计的模块复杂度尽可能的小、职责尽可能的单一。而直接使用包封装设计会使得每个包管理的资源比较多、单个包复杂度会比较高、并且存在过多同名包问题。因此我们需要将代码做分层设计(划分职责)、将包内容做进一步拆分(细化粒度),并将代码模块的粒度细化为了"对象"方式进行封装(这里的"模块"从package细化为了object)。目的是使得整体模块设计更加的解耦,能够快速响应业务发展的变化。对于业务项目而言,我们采用对象封装设计后,将会失去包循环依赖检测特性带来的好处,转而由开发者自行维护对象之间的依赖关系,正如我们熟悉的OOP那样。

GoFrame开发框架经过大量的项目工程实践,本着从简约、简洁、高效、易维护的设计理念出发,总结出了一些关于包设计和命名约束的最佳实践,可供参考。

二、对象封装设计

在代码分层设计之后,我们尽量地减少封装包的数量、降低包的复杂度,尽可能采用结构化对象的方式来封装代码处理逻辑。

1、业务包命名约束

在三层架构设计模式下,我们的业务包命名仅会有apidaomodelservice四个包。每个业务包仅对外暴露实例化的对象用于该业务领域的具体功能逻辑封装,同一层级下不同的业务领域逻辑通过不同文件来分别管理。包对外的公开对象采用业务领域名称来命名,包内部的数据结构定义采用业务领域名称+分层名称来命名,其中分层名称一般为apiservice,例如:

可以将业务领域名称看作特定业务的模块名字,例如:用户(user)、商品(product)、订单(order)、支付(payment)等。业务模块根据业务复杂度以及业务拆分粒度可大可小。

图1. api层公开对象及内部数据结构命名

图2. service层公开对象及内部数据结构命名

特别需要强调的是,在api/dao/service层级中的代码,有且仅有需要导出的实例化对象才能公开。并且由于同一包下包含多个业务领域的数据结构定义,因此在命名的时候务必遵从命名约束,否则容易出现命名冲突。采用单包管理以及实例化对象引用的设计,整个包对外引用简洁清晰、内部维护紧凑简便、规避循环引用问题。例如:

1、api层的对象访问

图3. 路由注册时访问api对象

图4. 注册具体业务领域对象的方法

2、service层的对象访问

图5. api访问service层对象

图5. 访问service层对象具体操作

2、model数据结构

model层级中的代码仅包含数据结构定义,不包含任何的方法封装。同时,model中也会包含公开的常量的一些定义。数据模型的命名直接使用业务领域名称,定义api/service输入输出的数据结构命名采用业务领域名称+分层名称,其他自定义的数据结构均需要带业务领域名称前缀,以方便区分同一包下的不同领域相关资源。例如,内容业务contentmodel中的所有数据结构定义均带有Content前缀:

图6. model数据结构命名

3、dao数据访问

我们通过将业务逻辑解耦后,dao层的代码仅包含一些通用的数据操作方法,代码往往通过工具生成,很少会有自定义数据封装方法的情况。并且dao往往只能被service调用,不应当被api调用,否则会出现循环依赖的问题。

图7. service调用dao对象示例

4、对象访问安全

由于各分层中的封装对象都是以"可变变量"的形式对外暴露使用,因此存在被修改的安全风险。因此大家注意这些公开的对象不要包含可修改方法、不要设置公开属性(通过公开方法暴露内部属性),并且建议以非指针(也尽量不要以接口)方式公开这些对象,例如:

图8. 安全风险:指针对象、公开属性

图9. 安全风险:指针对象、公开属性,修改建议

三、常见问题解答

1、如何自定义修改model的属性字段,如何扩展model属性字段

goframe提供了自动化的model生成工具,生成的数据表对象结构存放于仅可被model包内部访问的internal目录下。我们可以对model目录下程序中的数据结构进行自定义的修改和扩展。例如,我们有一个user表,生成的数据表实体对象数据结构如下:

图10. user数据表实体对象数据结构

图10. model中直接引用内部的数据表实体对象数据结构

假如该实体对象数据结构会被用于接口直接返回JSON被客户端,而我们不想返回其中的Password, CreatedAt, UpdatedAt 字段,我们可以直接修改model.User数据结构,将internal.User作为embedded内嵌数据结构,并在model.User数据结构中定义覆盖指定的字段属性(同样的,我们也可以如此扩展字段),如:

这里使用到了Golang的结构体embedded内嵌特性,Golang的内嵌特性可以简单实现类似于OOP中的继承和多态,并不是一个完整的OOP特性。除了struct可以内嵌,interface也支持内嵌特性,感兴趣的小伙伴可以执行研究一下,这里不作详细介绍。

图11. 数据结构embedded并自定义定义修改覆盖

关于扩展原有的数据结构示例,也可以参考后续【3、如何覆盖定义dao中已存在的方法,如何为dao扩展自定义方法】章节部分。

2、如果接口只需要表中的几个字段,如何对model结构体进行字段裁剪

举个例子。一个content内容数据表,字段会比较多,我们在对内容进行分页展示的时候,其实并不需要这么多字段。并且其中的Content字段内容较大,不适合列表展示,查询时也比较浪费数据库及带宽资源。因此我们可以根据不同的业务场景需求,重新定义该业务场景接口所需要的数据结构,单独维护该数据结构,而不是直接使用数据表的实体对象结构。

这里的数据表struct是数据模型,而用于列表展示中的struct数据结构其实是业务模型,具体的介绍请参考:数据模型与业务模型

图12. content数据表实体数据结构

重新定义的列表接口数据结构如下:

图13. 列表接口需要的数据结构,对数据表实体结构进行了字段裁剪

3、如何覆盖定义dao中已存在的方法,如何为dao扩展自定义方法

我们还是拿用户模块来举个例子吧。

覆盖方法:

比如我们要覆盖daoAll方法,增加状态名称和性别名称信息:

图14. 扩展原有数据结构

图15. 覆盖dao对象方法,重新自定义

扩展方法:

一般来说我们不需要为dao扩展方法(基本方法已经很通用)的需求,这个问题还困扰了我好久,因此我随便写个方法做个示范吧。为dao扩展方法很简单,直接为对应的dao对象定义新的方法即可,例如在本示例中,我们为userDao对象增加一个ExtendMethod方法:

图16. 为dao对象扩展定义方法


Content Menu

  • No labels

17 Comments

  1. Min

    不错的设计思想,学习了😊

    1. 你学习gf多久了

  2. 学习了,发现和我写的有相同的地方,不过另外有个疑问请教下,微服务,经常有trace 需要打通整个链路,这个结构体应该定义在service 中的req吗还是单独定义出来,每个函数在传递这个参数

    1. 微服务的链路跟踪由于需要在服务间共享数据结构,涉及到多个项目,因此如果链路需要传递自定义的数据结构,建议把这些数据结构单独提出来作为一个独立的包维护,以便项目间共享。放在protobuf中定义也可以。

  3. 设计思路真好,必须学习。

  4. 有service A 和 service B,dao A和dao B

    如果service A需要访问dao B的数据

    此时是service A直接访问dao B呢还是通过service B访问dao B呢?

    service A  --→ dao B

    service A --→ service B --→ dao B


    1. 在同一个业务系统下,dao是全局共享的,dao提供的是基础的数据集合CURD操作,往往不包含具体业务逻辑实现,因此各个service均可以直接访问指定的dao对象,这个时候:service A --> dao B。但如果要访问的数据是需要经过特定的业务逻辑处理后的结果,那么这个方法应当是封装到service B中,那么这个时候:service A --> service B --> dao B


      1. 这个service A  --→ dao B 很容易理解,但是service A --> service B --> dao B 这种一旦还有service B --> service  A --> dao B 的情况这样不就形成循环依赖了么,如何避免.

        如果是service A service B在同一个包下应该就可以避免是吧,我看示例focus项目貌似就是这么做的.拿focus项目来说, admin  index 分别有自己的service,如果admin,index有共用逻辑应该弄个全局service是吧.

        1. 是你,你理解得很到位。这篇文档也有介绍为何采用对象封装以及单包设计,其中一点就是为了解决cycle import的痛点,好好看看这篇文章哦。另外关于单应用多系统的业务场景,可以参考focus的一些设计介绍,建议你仔细阅读下这篇文章:单应用多系统设计

          goframeGolang工程化的最佳实践,是许多生产项目踩坑一步一步过来的。她把这些经验结合源码实现分享给大家,当你做项目的时候可以完全按照goframe推荐的工程化设计思想来做。

  5. roy

    你好,gf框架已经文档使用几月之久,感谢gf团队。

    本文叙述的思想使用挺长时间,调用方式和nodejs的eggjs的使用方式有着异曲同工之妙,思想挺好给个赞。

    想抛出一个点:这样的`单文件夹多文件`的目录设计,在横向业务激增的时候,代码量会逐渐变大。好比文本叙述点service/User文件,伴随用户业务越来越多,那被迫的会开出user_A/user_B各种文件。那整个service文件夹逐渐变大,可以考虑以文件夹分包的形式。service/user/A, service/user/B。


    按照实际业务背景考虑,仅做建议。

    1. 你好,感谢您对女朋友的支持!

      通过单包(目录)多文件的项目结构可以很方便地高内聚低耦合来管理业务模块相关源文件,例如service目录下,一个源文件对应一个业务模块的业务逻辑封装。一个系统的业务模块不会太多,一般也就几十个,对应目录下几十个源文件。如果一个业务模块太过于复杂通过单文件的方式不太好管理,不建议拆分为多个源文件来管理(例如service/user_a.go, service/user_b.go这样的方式),因为这样会使得目录文件非常庞大最后也不好管理了。我的几点建议方便参考:

      1、如果您的系统是使用的微服务架构,或者多服务化架构。这说明您的这个应用业务逻辑过于复杂,可能需要进一步进行服务化拆分,以保证单服务的轻量级和易维护性。

      2、如果您的系统本就是单体服务应用。那么您可以在对应的代码分层下通过internal包的形式在对应分层下重新组织业务模块的源文件。例如,拿用户模块来举个例子:

      具体的业务逻辑放到internal/user包中实现和管理,该包只能被service层的代码访问,而service包中的user.go通过对外暴露User实例化对象来访问具体的用户模块相关业务逻辑方法。

      1. roy

        了解你的意思了,我这边是service下直接对user等各类业务分了文件夹。在每个文件夹(包)下做业务的开发。外层使用的对象是在Import的时候直接到对应文件夹下暴露的对象。

        1. 您这样的话,就失去了GoFrame提到的代码分层和对象封装设计的意义了,而是仍旧采用的独立package包设计方式,项目到后面可能会遇到本文最开头提到的那些痛点。

          1. roy

            了解了,我尝试调整下,评估了下调整内容,应该需要在各类struct上做NewXXX去创建实例。谢谢支持

  6. 解耦思路很棒!

    这里我有个疑问,dao/api/service通过实例化struct才能公开,实际上是产生了一个package级别的变量。这种方式无法做到按需初始化,或者延迟初始化。例如只想调用service.User,但同时把其他变量也初始化了。

    通过实例化一个空struct来公开,是模仿OOP里静态方法?

    1. 您好,dao/api/service中暴露的对象是业务逻辑的封装对象,该对象往往是全局单例设计的。如果您涉及到按需初始化的业务场景,您可以考虑给对应的对象定义NewXXX方法来实现创建需要的实例化对象。举个例子,可以通过service.User.New()创建一个实例化的用户对象,service.User.NewStats()创建一个实例化的用户统计相关对象,以此类推。具体的逻辑你可以自行实现。

      1. 感谢回复。

        明白了。