简洁架构设计:如何设计一个合理的软件架构?

119 阅读13分钟

提示:

在开发项目之前,需要先设计一个合理的软件架构。一个好的软件架构不仅可以大大提高项目的迭代速度,还可以降低项目的阅读和维护难度。目前,行业中有多种流行的软件架构,例如:MVC 架构、六边形架构、洋葱架构、简洁架构等。在 Go 项目开发中,用的最多的是简洁架构。

本节课会详细介绍简洁架构,以及 miniblog 项目的简洁架构设计和实现方法。

为什么需要软件架构?

这里引用 Robert C.Martin 在其《Clean Architecture》书中的一句话,来说明为什么需要软件架构:软件架构的目标是最大限度地减少构建和维护系统所需的人力资源。

具体而言,采用一个合理的软件架构将带来以下好处:

  • 可测试性:良好的软件架构能够提高代码的可测性,从而增强软件的稳定性;
  • 可维护性:良好的软件架构使系统的各个部分相互独立,易于理解和修改。它提供了结构化的方式来组织代码,使系统的修改和维护变得更加简单;
  • 扩展性:软件架构应能够很好的支持系统的扩展和演变。通过合理的分层和模块化,软件架构可以使系统的功能很容易的得到扩展,而无需对整个系统进行重构;
  • 可重用性:好的软件架构能够提高代码的复用度。将通用的功能封装为可复用的包/库,可以使这些功能在不同的项目和模块中重复使用,从而提高开发效率和代码质量。

简洁架构介绍

简洁架构(Clean Architecture)是一种软件架构模式(又称整洁架构、干净架构),旨在实现可维护、可测试和可扩展的应用程序。最初由 Robert C.Martin 在其文节课 The Clean Architecture 提出。之后,因为简洁架构的诸多优点,在 Go 项目开发中被大量采用。

软件架构有多种形式,例如六边形架构、洋葱架构、尖叫架构、DCI 架构和 BCE 架构等。这些架构在细节上各有不同,但整体而言非常相似。它们的共同目标是实现关注点的分离,并通过软件的分层设计来达到这一目的,从而践行高内聚、低耦合的架构理念。

采用这些软件架构开发的应用都具有以下五点特性:

  • **独立于框架:**该架构不会依赖于某些功能强大的软件库存在。这可以让开发者使用这样的框架作为工具,而不是让开发者的系统陷入到框架的约束中;
  • **可测试性:**业务规则可以在没有 UI、数据库、Web 服务或其他外部元素的情况下进行测试,在实际的开发中,可以通过 Mock 来解耦这些依赖;
  • **独立于 UI:**在无需改变系统其他部分的情况下,UI 可以轻松地改变。例如,在没有改变业务规则的情况下,Web UI 可以替换为控制台 UI;
  • **独立于数据库:**开发者可以用 Mongo、Oracle、Etcd 或者其他数据库来替换 MariaDB,开发者的业务规则不要绑定到数据库;
  • **独立于外部媒介:**实际上,开发者的业务规则可以简单到根本不去了解外部世界。

上述五点特性,也可以看作是简洁架构的五点约束,理论上任何遵循了以上五点约束的软件架构,都可以看作是简洁架构的一种实现方式。通常所说的简洁架构指的是洋葱架构。

提示: Robert C. Martin 还为简洁架构专门写了一本书,如果你想了解更多简洁架构的知识,可阅读图书《架构整洁之道》。

miniblog 简洁架构实现

任何实现简洁架构规定的五个约束的软件架构均可称为简洁架构。miniblog 项目参考业界简洁架构的实现,也设计实现了一种简洁架构。与其他简洁架构的最大区别在于,miniblog 的简洁架构设计更加简单实用,省略了一部分分层特性,仅保留了必要的分层,但带来了更大的易读性和可维护性。

miniblog 项目的简洁架构设计如下图所示。

整个软件架构一共分为以下三层:

  • **Handler 层:**负责 API 接口请求的参数解析、参数校验、业务逻辑处理分发、参数返回逻辑。在 Handler 层中,还有 Default 和 Validation 模块,分别用来给请求参数设置默认值,并校验请求参数的合法性;
  • **Biz 层:**包括了具体的业务逻辑实现。Biz 层根据 REST 资源类型分为不同的模块,内部可模块间交叉调用。在 Biz 层还有 Conversion 模块,用来进行结构体类型转换;
  • **Store 层:**数据访问层(包括访问数据库或第三方微服务),用来跟数据库/微服务交互执行数据的 CURD 操作。该层做了进一步的抽象,抽象出了通用的 Store 层,Generic Store 之上 REST 资源的数据存储操作,均可继承 Generic Store 的方法实现,而不需要自行再实现一套。

上图所示的简洁架构,还具有以下特点:

  • 简洁架构提供了清晰的分层结构,各层功能明确,职责分明;
  • 通过接口解耦每一层,从而实现代码的可测性、独立性和扩展性;
  • 代码依赖由上向下(图中的有向箭头表示依赖规则),单向单层依赖,提供了清晰的依赖关系,使代码易于理解和维护。

上述三个特点也使得整个软件代码具有很高的易读性和可维护性。图 3-1 所示的简洁架构有三层,但这不意味着简洁架构只有三层。如果有需要你可以对层进行增减。虽然层数可变,但是依赖关系是固定的,即:单向依赖。

上图所示的简洁架构中,API 请求的数据流转路径如下图所示。

请求到来后,先经过 Default 模块,用来给请求参数设置默认值。之后,经过 Validation 模块,用来对请求参数进行校验。校验通过后,会经过 Handler 方法,Handler 方法会处理请求,并将请求转发到 Biz 层的 Biz 方法中。在 Biz 方法中需要进行数据转换,在 miniblog 项目中,会将 Biz 层的数据结构转换为 Store 层的数据结构,并调用 Store 层的方法,对数据进行 CURD 操作。Store 层的方法继承自 Generic Store,所以最终是调用 Generic Store 完成对数据的 CURD 操作。

简洁架构中的依赖规则

简洁架构能够工作的关键是依赖规则,这条规则规定:代码依赖应该由上向下,单向依赖。这种依赖包含代码名称、函数/方法、变量或任何其他软件实体。也就是说,下层不应该感知到上层的任何对象。上层中声明的数据格式不应被下层使用。

除了上述层与层之间的包依赖关系外,各层还可以导入项目所需的其他 Go 包,例如内部包、外部包或框架包等。然而,必须确保依赖关系的合理性,避免出现循环依赖。

上述包导入关系如下图所示。

简洁架构中的分层设计

miniblog 的简洁架构一共分为了三层:存储层(Store)、业务层(Biz)、处理器层(Handler)。每一层都承载了不同的功能。

存储层(Store)

存储层在某些简洁架构设计中也称为 Frameworks&Drivers 层或基础设施层。存储层负责与数据库、外部服务等进行交互,作为应用程序的数据引擎进行数据的输入和输出。需要注意的是,存储层仅对数据库或外部服务执行 CRUD 操作,不封装任何业务逻辑。

此外,存储层还承担数据转换的任务:将从数据库或微服务获取的数据转换为处理器层和业务层能够识别的数据结构,同时将处理器层和业务层的数据格式转换为数据库或外部服务可识别的数据格式。

业务层(Biz)

业务层在(Biz,Business)某些简洁架构设计中也称为 Usecases 层。业务层是领域模型的应用层,负责协调各个实体和值对象之间的交互,以完成具体的业务需求。业务层会受到业务逻辑变更的影响,但不会被其他层所影响,例如用户界面和数据库等。

业务层功能如下图所示。

在实际的企业应用开发中,业务层是变更最频繁的一层。

处理器层(Handler)

处理器层在某些简洁架构设计中也称为控制器层。处理器层负责接收 HTTP/RPC 请求,并进行参数解析、参数校验、业务逻辑处理、请求返回等操作。处理器层的核心目的是将用户的输入转化为领域模型的操作,并将结果返回给用户。在这一层还包括其他适配器,用于将数据从外部形式(如外部服务)转换为业务层可以使用的内部形式。

处理器层会将请求转发给业务层,业务层处理后返回,返回数据在处理器层中被整合再加工,最终返回给请求方。处理器层相当于实现了业务路由的功能。具体流程如下图所示。

提示:

在 MVC 架构中,处理器层通常用 Controller 来表示,而在 gRPC 服务中则用 Service。为了统一 MVC 架构中的处理器层名称与 gRPC 服务中的处理器层名称,这里统一使用 Handler 来表示处理器层。在大多数 Go 项目中,包括一些优秀的开源项目(如 Kubernetes、Gin、Echo 等),处理请求的层通常被命名为 Handler,而非 Controller 或其他名称。Handler 准确地表达了其职责,即负责处理(handling)请求。

接口依赖关系

在简洁架构的设计中,各层之间通过接口进行解耦,以便减少依赖关系,同时增强系统的扩展性。接口依赖有以下两种模式:

  • **接口依赖方式一:**外层组件声明所需的能力,内层组件则实现这些能力;
  • **接口依赖方式二:**内层组件首先提供所需能力(接口),外层组件才能调用这些能力。外层组件的能力依赖于内层组件的能力。

接口依赖模式如下图所示。

在接口依赖方式一种,包的导入关系为内层导入外层。在接口依赖方式二中,包的导入关系为外层导入内层。miniblog 项目采用了第二种接口依赖方式,即在开发过程中优先开发内层组件,然后再开发外层组件。具体的开发流程为:先开发 Store 层、Biz 层,最后是 Handler 层。

层之间的通信

处理器层、业务层和存储层之间均通过接口进行通信。通过接口通信,一方面可以支持同一个功能有不同的实现(也就是说具有插件化能力)。另一方面,接口解耦了不同层的具体实现,使得每一层变得独立且可测试。层之间通信模式如下图所示。

简洁架构如何测试

处理器层、业务层和存储层之间通过接口进行通信。通过接口通信的一个好处是,可以让各层变得可测试。本节将讨论如何测试各层的代码。

存储层测试

存储层依赖于数据库,如果调用了其他微服务,则还会依赖第三方服务。开发者可以通过 sqlmock 来模拟数据库连接,通过 httpmock 来模拟 HTTP 请求。

业务层测试

业务层依赖于存储层,这意味着该层需要存储层的支持才能进行测试。可以使用 golang/mock 来模拟存储层,测试用例可以参考 Test_postBiz_Delete,单元测试用例代码如下述代码所示。

func Test_postBiz_Delete(t *testing.T) {
    // 创建一个新的 gomock 控制器,用于管理 Mock 对象
    ctrl := gomock.NewController(t)
    defer ctrl.Finish() // 确保在测试结束时调用 Finish,以验证所有预期的调用

    // 构造 Mock 的 PostStore
    mockPostStore := store.NewMockPostStore(ctrl)
    // 设置对 Delete 方法的期望:在上下文中调用一次,传入任意参数,返回 nil(表示没有错误)
    mockPostStore.EXPECT().Delete(context.Background(), gomock.Any()).Return(nil).Times(1)

    // 构造 Mock 的 IStore
    mockStore := store.NewMockIStore(ctrl)
    // 设置对 Posts 方法的期望:可以被调用任意次数,返回 mockPostStore
    mockStore.EXPECT().Post().AnyTimes().Return(mockPostStore)

    // 初始化 postBiz 实例,传入 Mock 的 IStore
    biz := &postBiz{store: mockStore}

    // 执行 Delete 方法,传入上下文和一个空的 DeletePostRequest
    got, err := biz.Delete(context.Background(), &apiv1.DeletePostRequest{})

    // 使用 assert 进行断言,检查返回的结果是否与期望的 DeletePostResponse 相等
    assert.Equal(t, &apiv1.DeletePostResponse{}, got, "Expected response does not match")
    // 检查 err 是否为 nil,确保没有错误发生
    assert.Nil(t, err, "Expected no error, but got one")
}

上述代码使用 golang/mock 工具生成了存储层的 Mock 方法 NewMockPostStore 和 NewMockIStore。

处理器层测试

处理器层依赖于业务层,这意味着该层需要业务层的支持进行测试。同样可以通过 golang/mock 来模拟业务层,测试用例可参考 TestHandler_DeletePost。

小结

本节课介绍了 miniblog 项目中使用的简洁架构设计。在 miniblog 项目中,架构被简化为存储层、业务层和处理器层,各层通过接口解耦,职责明确,提升了代码的清晰度和可测试性。遵循的依赖规则确保了代码依赖由外向内、单向传递。此外,接口的使用提升了层之间的通信能力,使得各层可以独立测试,使得可以通过单元测试用例来提高代码的稳定性。

  • 知识星球:云原生 AI 实战营。10+ 高质量体系课( Go、云原生、AI Infra)、15+ 实战项目,P8 技术专家助你提高技术天花板,冲击百万年薪!
  • 公众号:令飞编程,分享 Go、云原生、AI Infra 相关技术。回复「资料」免费下载 Go、云原生、AI 等学习资料;
  • 哔哩哔哩:令飞编程 ,分享技术、职场、面经等,并有免费直播课「云原生 AI 高新就业课」,大厂级项目实战到大厂面试通关;