在工作中,我接触到的产品均采用了微服务架构,后端项目开发普遍采用了六边形架构:六边形架构提供了一套良好的设计思想,但它缺乏对项目代码组织细节的指导;同时,项目中并没有使用专门的微服务框架,而是普遍使用Gin框架,这使得代码组织过于灵活,没有提供充分的编码约束,以致于在过去的业务需求实现中,后端服务的项目代码组织充斥着各种各样的问题;为了解决这些问题、提高开发效率、保障工程质量,基于工作一年的实践和探索设计了一套新的方案,现在此分享。

项目结构

目录结构

├── common  公共模块
│   ├── config.go  依赖配置
│   ├── constants.go  公共常量
│   └── utils.go  通用工具函数
├── drivenadapters  从动适配器
│   ├── cache  缓存适配器
│   ├── db  数据库适配器
│   ├── http  HTTP服务适配器
│   ├── mq  消息队列适配器
│   └── repository  资源库实现,完成依赖注入
├── driveradapters  驱动适配器
│   ├── api  同步WEB接口适配器
│   │   ├── health.go  健康检查接口控制器
│   │   ├── middleware  路由中间件
│   │   └── router.go  路由模块,进行路由注册
│   ├── async 异步WEB接口适配器
│   │   └── init.go  消息网关模块,进行异步接口注册
│   └── cmd 命令行接口适配器
│       ├── processor.go  处理器模块,进行命令注册
│       └── server.go 服务启动命令执行器
├── errors  业务错误
│   ├── general.go  通用业务错误 
│   └── custom.go  自定义业务错误
├── infra  基础设施
├── logics  核心业务逻辑
│   ├── pipeline  消息发送管道
│   ├── proxy  服务代理接口
│   └── dependency  资源库接口,实现依赖倒置
├── main.go  服务入口
└── models  数据模型

模块划分

项目中的模块分为核心与适配器,其中核心为业务逻辑,适配器为系统与外部通信的模块。

核心

  1. 将每个独立的业务模块称为服务(Service),负责组装业务流程。
  2. 每个服务都有一个专属的资源库(Repository),负责完成业务逻辑中的数据处理操作。
  3. 资源库的接口由服务定义,并通过聚合从动适配器来实现,以此实现依赖倒置
  4. 资源库必需在服务之前进行实例化,以完成依赖注入

适配器

  1. 驱动适配器(DriverAdapter)从动适配器(DrivenAdapter):这是基于模块调用的逻辑关系来划分的,被核心调用的部分叫从动适配器,调用核心的部分叫驱动适配器
  2. 出栈适配器(PopAdapter)入栈适配器(PushAdapter):这是基于数据流向来划分的,数据流入核心的部分叫入栈适配器,数据流出核心的部分叫出栈适配器
  3. 工作中遇到许多微服务项目错误地使用入栈适配器出栈适配器的概念,导致了项目代码结构组织逻辑的混乱;在系统中,模块之间的数据流动往往是双向的(比如HTTP的请求和响应),所以不适用上述划分标准。
  4. 驱动适配器从动适配器是六边形架构中常用的概念,需要注意应当从逻辑上而非物理上划分,否则会无法处理一些特殊的情况(如消息的订阅与发布)。

项目架构

项目架构

设计思想

  1. 数据驱动设计:通常,数据存储格式相对稳定,而业务逻辑经常需要调整和扩展,所以优先明确业务数据模型,并围绕它来组织业务逻辑、实现业务流程。
  2. 六边形架构:分离系统的核心与外部,两者通过端口实现交互;业务逻辑定义端口,适配器实现端口。
  3. 分层架构原则:禁止下层模块调用上层模块。
  4. 依赖倒置原则:上层模块不依赖于下层模块,两者都依赖于抽象;抽象不依赖于实现,实现依赖于抽象。
  5. 关注点分离:每个模块只关注自己的职责:禁止同一层模块间的相互调用,模块内使用的常量非不要不暴露给外部。
  6. 显式管理依赖:向开发人员暴露不同模块间的调用链路,以便于排查问题根源和预防循环依赖。
  7. 职责归纳分类:在从动适配器和驱动适配器中,将适配器模块根据外部依赖的类别不同划分到不同的包中。

实践指导

职责划分

目录内容说明
models数据模型1. 定义业务逻辑层与适配器层通信用的数据模型。
2. 数据模型上只有属性没有方法。
3. 依据数据模型的来源拆分类型定义拆分到不同文件中。
common公共组件1. config统一管理项目的静态配置常量,基础设施连接配置除外。
2. constants统一管理项目中不同模块间通用的常量。
3. utils统一管理业务无关的通用工具函数。
infra基础设施1. 统一管理服务对数据库/缓存/消息队列等的访问。
2. 封装访问基础设施的静态配置,并提供创建/获取客户端对象实例的简单方法。
3. 当有特殊需要时,封装新的模块实现客户端对象接口,并在对应的方法中添加处理逻辑。
errors业务错误1. 统一管理drivenadapter中返回的业务错误对象。
2. general提供生成通用业务错误对象的工具,custom提供生成自定义业务错误对象的工具。
3. 业务错误对象由driveradapters中的适配器模块或logics中的Service模块生成。
drivenadapters从动适配器1. db管理访问数据库的适配器,按照数据对象实体关系划分模块。
2. cache管理访问缓存的适配器,按照缓存数据来源划分模块。
3. http管理访问HTTP服务的适配器,按照提供接口的服务划分模块。
4. mq管理访问消息队列的适配器模块,按照定义消息的服务划分模块。
5. 基于相同外部服务的适配器按照具体用途放入不同的目录(如基于OpenSearch的适配器可能归入db/cache/http,基于Redis的适配器可能归入db/cache/mq)
6. repository实现logics层中dependency定义的Repo接口,并进行依赖注入;每个Repo对象会聚合不同的适配器模块,并完成数据处理。
logics核心业务逻辑1. 按照业务职责划分出不同的Service模块,根据Service定义对应的Repo,负责处理业务逻辑所需的数据操作。
2. dependency管理Repo接口,并提供实例对象的Set/Get函数;通常情况下,一个Repo只有一个实现,如有多个实现则需采用工厂模式。
3. 不同Service之间可能需要相互调用,按照以下方式实现(假设有两个Service为X和Y):
    X依赖Y的方法返回值时
        1. 在dependency中定义Proxy接口。
        2. 在Y的构造函数中创建实例后调用Set函数注入Proxy实例。
        3. 在X的Repo接口中聚合上述Proxy接口。
        4. 在repository中Repo的实现中添加Proxy上方法的实现,在每个方法中通过Proxy接口的Get方法动态获取实例。
    X不依赖Y的方法返回值时,但改变Y的状态时
        1. 创建一个EventLoop实例(具体实现不限制)。
        2. X发布事件A到EventLoop。
        3. Y订阅事件A并进行处理。
driveradapters驱动适配器1. api提供同步WEB接口,按照路由所属业务划分controller模块,负责解析请求参数并作通用性校验、调用logics中Service进行处理、封装并放回请求响应;middleware提供路由中间件,health为健康检查接口,router负责统一注册controller的路由处理。
2. cmd提供命令行调用接口,按照业务划分executor模块,processor负责统一注册executor的命令处理,server是负责启动服务的特殊命令。
3. async提供异步WEB接口,按照业务划分broker模块,init负责统一注册broker的消息处理。

编码顺序

编码顺序

  1. 定义业务数据模型

    • 根据实现设计来编写类型声明,在进入编码开发前必须充分明确实现业务所需的数据模型。
    • 根据数据库设计定义数据持久化对象PO,结构体属性字段与数据库表字段一一对应。
    • 根据API设计定义值对象VO,此时只需明确字段名称和类型。
    • 响应结构体属性字段与接口响应参数字段一一对应。
    • 请求结构体属性字段聚合接口请求体、查询参数、路径参数、请求头中所需字段。
    • 根据缓存数据模型设计定义缓存对象。
    • 消息结构直接使用公共数据模型,有必要时定义消息对象。
    • 按需添加业务域内通用的公共数据模型。
  2. 定义Service接口

    • 明确Service需要暴露给接口调用的方法。
    • 对于供Controller调用的方法,参数只有一个请求值对象,返回值只能包含响应值对象和error。
    • 对于供Broker调用的方法,参数只有一个消息对象,返回值只有一个error。
  3. 定义Repo接口

    • 明确Service方法实现业务流程所需完成的业务数据操作,抽象为Repo接口上的方法。
    • 定义Repo实例的Set和Get方法。
    • 定义DTO对象。
  4. 编写Service的UT

    • 根据核心业务流程编写Service接口方法的UT。
    • 编写UT专用的构造函数生成注入mock对象的Service实例。
    • 针对业务错误编写对应分支的UT,不考虑与业务错误无关的error分支。
    • 对日志打印方法调用进行mock时,不关注其执行的时机和次数。
    • 断言error时,只关注是否为nil,不关注具体是什么error。
  5. 实现Service

    • 根据接口生成Repo和infra的mock以便于运行UT。
    • 根据UT实现Service接口上的方法,按需定义业务错误码和描述信息,并生成业务错误对象。
    • 服务初始化所需的处理写在私有方法里,并在实例化的构造函数里调用。
  6. 定义DrivenAdapters接口

    • 明确Repo方法实现业务数据操作所需的底层数据操作,抽象出多个DrivenAdapter接口。
    • RDS的适配器接口暴露的每个方法内部的操作都在同一个事务中完成,特殊情况是Repo需要聚合多个数据库适配器的操作时,将事务对象暴露在方法参数中由Repo控制。
    • Redis的适配器接口暴露的每个方法内部的操作都对应Redis的一个命令或事务。
    • 每个消息订阅方法都对应一种msg结构,通过参数指定topic和channel。
    • 每个消息发布方法都对应一对topic和msg的组合。
    • HTTP服务适配器接口暴露的每个方法都对应一个HTTP请求。
  7. 编写Repo的UT

    • 根据业务数据处理逻辑编写Repo接口方法的UT。
    • 编写UT专用的构造函数生成注入mock对象的Repo实例。
    • 覆盖具有系统性价值的逻辑分支,不考虑错误分支。
  8. 实现Repo

    • 根据接口生成DrivenAdapter和infra的mock以便于运行UT。
    • 根据UT实现Repo接口上的方法。
    • 负责管理业务使用的但独立于业务流程外的状态。
  9. 实现DrivenAdapters

    • 实现接口上的方法,内部的处理逻辑按需提取拆分出私有方法。
    • RDS的适配器处理SQL语句的生成与执行,并打印事务提交/回滚出错的日志。
    • Redis的适配器管理静态key,并在接口方法内调用私有方法给参数加前缀生成动态key。
    • 消息队列适配器中,消息订阅方法处理消息结构的反序列化,消息发布方法处理消息结构的序列化,出错时均需打印错误日志。
    • HTTP服务适配器管理请求接口URL,进行请求参数的包装后发送请求,并根据HTTP响应状态码处理响应参数或业务错误的解析。
  10. 实现DriverAdapters

    • 控制器接口按需暴露注册开放接口和注册私有接口的方法,方法内部挂载路由处理方法和所需中间件。
    • 处理通用参数校验,并生成相应的通用错误对象。
    • 定制参数校验在控制器层定义私有方法进行处理,按需定义服务通用业务错误码和描述信息,并生成业务错误对象。
    • 消息适配器中发布方法定义topic和msg结构并处理其序列化,订阅方法处理msg结构的反序列化。
  11. 编写Adapters的UT

    • 按需编写UT,不必考虑error。
    • 从动适配器的UT优先级高于驱动适配器。
    • 优先给复杂的数据库查询操作编写UT,对SQL语句的mock要尽可能精准匹配。
    • 消息订阅和发布的处理方法不需要编写UT,因为它们职责简单、逻辑单薄。
    • 缓存操作通常也不需要编写UT,除非有部分方法包含了复杂的操作逻辑,编写UT时对redis命令的mock要尽可能精准匹配。
    • HTTP请求方法中,如果包含复杂的响应参数处理逻辑,可以编写UT。
    • 控制器方法中,如果包含定制参数校验逻辑,可以编写UT。

操作手册

编号参考项具体规则
1常量定义1. 当字面量具有特定逻辑含义时,声明为常量。
2. 使用array将int常量映射为string常量。
3. 使用map将string常量映射为int常量。
4. 当一组枚举值的业务语义存在上下文时,使用struct的public属性聚合它们。
2错误处理1. 对于业务不允许的err,使用%w封装添加来源后return。
2. 从动适配器不屏蔽err,将其封装后留给Repo处理。
3. 在Repo中遇到err时,根据业务逻辑需要决定忽略或return。
4. 在Service中遇到业务允许的err时,生成业务错误对象后return。
5. 在服务初始化时,panic内部err,for err != nil轮询刷新外部err。
3日志打印1. 服务初始化成功打印info日志,失败打印error日志。
2. Service中遇到业务不允许的err时,打印error日志。
3. Repository中遇到Service中无法捕获的错误时,打印error日志。
4. 数据库事务提交/回滚失败时,打印error日志。
5. 异步消息发送/处理失败时,打印error日志。
4命名约定1. 从动适配器
    1. 缓存适配器:业务实体+Cache
    2. 数据库适配器:业务实体+Store
    3. HTTP服务适配器:服务名+Client
    4. 消息队列适配器:服务名+Broker
2. 业务逻辑层
    核心业务逻辑模块Service:业务名+Service;
    业务数据处理模块Repository:业务名+Repo
    业务服务代理接口:业务名+Proxy
3. 驱动适配器
    1. 同步WEB接口适配器:路由组前缀+Controller
    2. 命令行接口适配器:指令名+Executor
    3. 异步WEB接口适配器:业务名+Broker
4. 数据模型
    1. 缓存数据模型:数据实体+Cache
    2. 驱动适配器定义的数据模型:接口标识+VO(值对象)
    3. 数据库适配器定义的数据模型:数据实体+PO(持久化对象)
    4. Repo定义的数据模型:数据实体+DTO(数据传输对象)
5方法调用当模块调用其它层模块时,传入参数/返回值必须使用基本类型/公共数据模型/指定类型:
    1. 驱动适配器调用Service:VO
    2. Service调用Repo:DTO
    3. Repo调用数据库适配器:PO
    4. Repo调用缓存适配器:Cache
    5. Repo调用HTTP服务适配器:适配器模块内定义的类型
6逻辑实现1. 方法抽象:通过interface暴露方法,通过struct实现interface。
2. 属性封装:struct用private属性保存局部状态,不允许包含public属性。
3. 单向数据流:外部改变模块状态必须通过调用其interface上的方法来执行。
4. 无副作用:一个模块的方法A调用其它模块方法B时,B对传入参数状态的改变不能影响到A。
7依赖注入1. dependency中提供Repo的Set函数。
2. repository中每个Repo使用init函数进行初始化,其内部调用Set函数,传入参数为调用Repo实现模块的构造函数返回的Repo对象。
8路由注册1. Controller对象按需实现PublicController和PrivateController接口。
2. Router模块提供RegisterPrivateAPI方法注册内部接口,它会调用PrivateController上的RegisterPrivate方法。
3. Router模块提供RegisterPublicAPI方法注册开放接口,它会调用PublicController上的RegisterPublic方法。
4. Router模块实例化时,将Controller实例注入PublicController列表和PrivateController列表。
5. main.go中实例化Router并调用其RegisterPublicAPI和RegisterPrivateAPI方法统一注册路由。
9模块通用1. 建议使用基于sync.Once+构造函数实现的懒汉式单例,以便于UT进行mock。
2. 被调用接口按调用方需求暴露方法,隐藏其内部实现细节。
3. 不允许将整个方法内部的处理逻辑包装到go协程中,由调用方决定是否异步执行。
10数据库操作1. 一个适配器模块对应一组业务数据,这些数据逻辑上属于同一业务实体。
2. 每个方法最多直接管理一条SQL语句。
3. 执行参数可变的SQL语句时,必须使用参数化查询,不允许使用字符串拼接。
4. 对于SQL语句查询参数的边界情况,在调用它的最上层方法中处理。
5. 批量更新记录时,优先考虑增量更新而不是全量更新。6.
事务使用
    1. 单独使用的SELECT语句不需要显式开启事务。
    2. 操作包含UPDATE/DELETE/INSERT语句时,必须使用事务。
    3. 多个SELECT语句组成的查询操作,在要求强一致性场景下使用事务。
    4. 只在有必要的情况下将事务对象暴露给Repo层使用,不允许暴露给Service层。
11缓存操作1. 一个适配器模块对应一个数据源,可以是logics里的某个Service或某个外部HTTP服务。
2. 每个方法最多直接管理一条Redis指令。
3. 缓存key使用
    1. 将可变key的生成逻辑封装在内部,接口暴露的方法传参只需要传入缓存对象的唯一标识。
    2. 将不可变key存在struct的私有属性上,接口暴露的方法不需要传入相关参数。
4. value的数据类型选择
    1. 只需要全量更新和整体获取的缓存对象使用string类型。
    2. 需要增量更新或局部获取的缓存对象:
        1. 包含唯一标识的项集合使用hash。
        2. 值唯一的无序简单项集合使用set。
        3. 值唯一的有序简单项集合使用zset。
    4. 值不唯一的有序简单项集合使用list。
5. 缓存一致性问题
    1. 当前服务内管理的持久化数据可以设置不过期,服务更新数据时同步到缓存;追求强一致时缓存只更新不删除并设置过期时间。
2. 外部来源的缓存数据根据一致性强弱要求设置合适的过期时间,条件允许时同步更新。
12HTTP请求1. 一个适配器模块对应一个外部HTTP服务,主要是其它的业务服务。
2. 每个方法最多直接管理一个HTTP请求。
3. HTTP请求发送成功,但响应状态码不正常时,检测是否为业务错误,是则解析后赋值给err。
4. 按照适配服务的API设计定义方法的返回值/参数结构,固定参数可以封装在方法内而不暴露到接口上,接口方法使用的参数结构定义直接写在模块对应的源码文件里。
13消息处理1. 一个消息队列适配器对应一组消息业务,可以是logics里的某个Service或某个外部HTTP服务。
2. 从动适配器中,每个消息发布方法将msg结构暴露在参数中,将topic封装在方法内。
3. 从动适配器中,每个消息订阅方法处理一种结构的消息
    1. 将topic、channel封装在方法内,handler暴露在参数中
    2. handler接收参数类型根据消息接口定义,msg在方法内部序列化
    3. 由Repo调用从动适配器提供的方法,传入handler进行消息订阅
4. 驱动适配器中,每个订阅接口方法发布消息,通过注入pipeline供Service模块取用。
5. 驱动适配器中,每个消费接口方法内部订阅消息,并通过Service接口暴露的方法进行处理,所有方法共用一个channel。
14资源库定义1. 一个Repo接口对应一组业务数据处理需求,对应唯一的Service。
2. 每个Repo使用全局变量保存实例,提供Get函数获取实例,Set函数注入实例。
3. 接口方法使用的DTO结构定义直接写在接口声明所在的源码文件里。
4. 当一个Repo存在不同实现时,采用工厂模式实现,使用map[string]Repo保存多个不同实例,将key作为参数传入Get/Set函数用于指定获取/注入哪个实例。
5. Repo上的方法主要为Service服务,不需要像从动适配器一样强的通用性。
15资源库实现1. 在init函数中调用Set函数注入Repo的实例,通过环境变量控制是否实例化,以允许执行UT时跳过此过程
2. Repo实例按需聚合多个从动适配器,并实现供Service调用的接口方法。
3. 获取/更新数据时,缓存与持久化数据的同步在Repo中处理,不暴露给Service。
4. 与业务流程无关的状态由Repo管理,不暴露给Service。
5. 基于其它HTTP服务的业务错误响应完成的逻辑处理在Repo内完成,不暴露给Service。
16路由控制器1. 一个Controller适配器对应一组业务相关的路由接口。
2. 每个Controller按需实现RegisterPublic/RegisterPrivate方法,将路由注册委托给Router。
3. 按需补充中间件供接口注册路由时挂载。
4. 对于Service返回的err,业务错误直接返回响应,其它错误封装成通用500错误响应。
5. 处理通用的参数校验。
17业务服务实现1. 一个Service对应一个业务服务,并定义一个对应的Repo。
2. 每个Service使用私有属性保存状态,且状态只能由Service自身变更。
3. 严格根据业务域拆分Service,将不涉及Service状态的公共部分下沉到Repo中。
4. 对于涉及Service状态重叠部分的Service间调用,根据业务核心逻辑决定归属的Service,并通过Proxy接口将该Service对象注入Repo中供其它Service调用。
5. 不允许Service直接调用从动适配器,必须通过Repo做中介。

其它

编码注意

  1. 在异常情况/错误处理中,使用卫语句代替else分支减少嵌套;逻辑上并列的分支仍使用else。
  2. 卫语句:将异常/错误分支前置,判断不通过时直接return,以降低圈复杂度(即最大嵌套层数)。
  3. 单个方法/函数逻辑中专用的多分支逻辑或复杂的多分支逻辑使用switch-case,多个方法/函数通用的简单多分支逻辑使用表驱动法。
  4. 表驱动:用hash表存储一组键值对映射,根据入参匹配key来获取value使用,以代替等值匹配的多条件分支。
  5. 拼接字符串的逻辑不复杂时,推荐使用+而不是fmt库方法。
  6. 能提前确定size时,调用make创建map/slice时直接传入size,此时slice通过索引赋值元素。
  7. 函数/方法传参/返回struct尽量用指针类型,slice/map则不要用指针类型。
  8. 在函数/方法的返回值列表中声明的非基本类型参数不会做初始化(因为只是声明),所以map/slice/chan/*struct类型需要在函数体内手动赋初值。
  9. 使用工具库flex简化代码并高效实现逻辑。

并发安全

  1. 在无法捕获panic的协程中做类型断言时,使用第二个参数来预防panic。
  2. 可能出现并发读写map的场景,使用sync.Map/sync.Mutex/chan来防止panic。
  3. 使用细粒度的数据操作控制来预防并发写导致的数据冲突,同时适用于缓存和数据库。
  4. 多实例场景下仅需要单次执行的操作,通过分布式锁使后访问数据的服务实例跳过操作。
  5. 只在逻辑处理有必要异步的场景下使用go协程,以避免带来额外的维护复杂度。

示例项目

这里提供了不同语言的参考示例项目,可以直接作为搭建新的微服务项目的代码模板使用,同时也欢迎贡献未收录编程语言的示例项目。

  1. Go

BioCrossCoder
13 声望6 粉丝

编程爱好者,后端工程师,想当架构师