在工作中,我接触到的产品均采用了微服务架构,后端项目开发普遍采用了六边形架构:六边形架构提供了一套良好的设计思想,但它缺乏对项目代码组织细节的指导;同时,项目中并没有使用专门的微服务框架,而是普遍使用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 数据模型
模块划分
项目中的模块分为核心与适配器,其中核心为业务逻辑,适配器为系统与外部通信的模块。
核心
- 将每个独立的业务模块称为
服务(Service)
,负责组装业务流程。 - 每个服务都有一个专属的
资源库(Repository)
,负责完成业务逻辑中的数据处理操作。 资源库
的接口由服务
定义,并通过聚合从动适配器
来实现,以此实现依赖倒置
。资源库
必需在服务
之前进行实例化,以完成依赖注入
。
适配器
驱动适配器(DriverAdapter)
和从动适配器(DrivenAdapter)
:这是基于模块调用的逻辑关系来划分的,被核心调用的部分叫从动适配器
,调用核心的部分叫驱动适配器
。出栈适配器(PopAdapter)
和入栈适配器(PushAdapter)
:这是基于数据流向来划分的,数据流入核心的部分叫入栈适配器
,数据流出核心的部分叫出栈适配器
。- 工作中遇到许多微服务项目错误地使用
入栈适配器
和出栈适配器
的概念,导致了项目代码结构组织逻辑的混乱;在系统中,模块之间的数据流动往往是双向的(比如HTTP的请求和响应),所以不适用上述划分标准。 驱动适配器
和从动适配器
是六边形架构中常用的概念,需要注意应当从逻辑上而非物理上划分,否则会无法处理一些特殊的情况(如消息的订阅与发布)。
项目架构
设计思想
- 数据驱动设计:通常,数据存储格式相对稳定,而业务逻辑经常需要调整和扩展,所以优先明确业务数据模型,并围绕它来组织业务逻辑、实现业务流程。
- 六边形架构:分离系统的核心与外部,两者通过端口实现交互;业务逻辑定义端口,适配器实现端口。
- 分层架构原则:禁止下层模块调用上层模块。
- 依赖倒置原则:上层模块不依赖于下层模块,两者都依赖于抽象;抽象不依赖于实现,实现依赖于抽象。
- 关注点分离:每个模块只关注自己的职责:禁止同一层模块间的相互调用,模块内使用的常量非不要不暴露给外部。
- 显式管理依赖:向开发人员暴露不同模块间的调用链路,以便于排查问题根源和预防循环依赖。
- 职责归纳分类:在从动适配器和驱动适配器中,将适配器模块根据外部依赖的类别不同划分到不同的包中。
实践指导
职责划分
目录 | 内容 | 说明 |
---|---|---|
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的消息处理。 |
编码顺序
定义业务数据模型
- 根据实现设计来编写类型声明,在进入编码开发前必须充分明确实现业务所需的数据模型。
- 根据数据库设计定义数据持久化对象PO,结构体属性字段与数据库表字段一一对应。
- 根据API设计定义值对象VO,此时只需明确字段名称和类型。
- 响应结构体属性字段与接口响应参数字段一一对应。
- 请求结构体属性字段聚合接口请求体、查询参数、路径参数、请求头中所需字段。
- 根据缓存数据模型设计定义缓存对象。
- 消息结构直接使用公共数据模型,有必要时定义消息对象。
- 按需添加业务域内通用的公共数据模型。
定义Service接口
- 明确Service需要暴露给接口调用的方法。
- 对于供Controller调用的方法,参数只有一个请求值对象,返回值只能包含响应值对象和error。
- 对于供Broker调用的方法,参数只有一个消息对象,返回值只有一个error。
定义Repo接口
- 明确Service方法实现业务流程所需完成的业务数据操作,抽象为Repo接口上的方法。
- 定义Repo实例的Set和Get方法。
- 定义DTO对象。
编写Service的UT
- 根据核心业务流程编写Service接口方法的UT。
- 编写UT专用的构造函数生成注入mock对象的Service实例。
- 针对业务错误编写对应分支的UT,不考虑与业务错误无关的error分支。
- 对日志打印方法调用进行mock时,不关注其执行的时机和次数。
- 断言error时,只关注是否为nil,不关注具体是什么error。
实现Service
- 根据接口生成Repo和infra的mock以便于运行UT。
- 根据UT实现Service接口上的方法,按需定义业务错误码和描述信息,并生成业务错误对象。
- 服务初始化所需的处理写在私有方法里,并在实例化的构造函数里调用。
定义DrivenAdapters接口
- 明确Repo方法实现业务数据操作所需的底层数据操作,抽象出多个DrivenAdapter接口。
- RDS的适配器接口暴露的每个方法内部的操作都在同一个事务中完成,特殊情况是Repo需要聚合多个数据库适配器的操作时,将事务对象暴露在方法参数中由Repo控制。
- Redis的适配器接口暴露的每个方法内部的操作都对应Redis的一个命令或事务。
- 每个消息订阅方法都对应一种msg结构,通过参数指定topic和channel。
- 每个消息发布方法都对应一对topic和msg的组合。
- HTTP服务适配器接口暴露的每个方法都对应一个HTTP请求。
编写Repo的UT
- 根据业务数据处理逻辑编写Repo接口方法的UT。
- 编写UT专用的构造函数生成注入mock对象的Repo实例。
- 覆盖具有系统性价值的逻辑分支,不考虑错误分支。
实现Repo
- 根据接口生成DrivenAdapter和infra的mock以便于运行UT。
- 根据UT实现Repo接口上的方法。
- 负责管理业务使用的但独立于业务流程外的状态。
实现DrivenAdapters
- 实现接口上的方法,内部的处理逻辑按需提取拆分出私有方法。
- RDS的适配器处理SQL语句的生成与执行,并打印事务提交/回滚出错的日志。
- Redis的适配器管理静态key,并在接口方法内调用私有方法给参数加前缀生成动态key。
- 消息队列适配器中,消息订阅方法处理消息结构的反序列化,消息发布方法处理消息结构的序列化,出错时均需打印错误日志。
- HTTP服务适配器管理请求接口URL,进行请求参数的包装后发送请求,并根据HTTP响应状态码处理响应参数或业务错误的解析。
实现DriverAdapters
- 控制器接口按需暴露注册开放接口和注册私有接口的方法,方法内部挂载路由处理方法和所需中间件。
- 处理通用参数校验,并生成相应的通用错误对象。
- 定制参数校验在控制器层定义私有方法进行处理,按需定义服务通用业务错误码和描述信息,并生成业务错误对象。
- 消息适配器中发布方法定义topic和msg结构并处理其序列化,订阅方法处理msg结构的反序列化。
编写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. 外部来源的缓存数据根据一致性强弱要求设置合适的过期时间,条件允许时同步更新。 |
12 | HTTP请求 | 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做中介。 |
其它
编码注意
- 在异常情况/错误处理中,使用卫语句代替else分支减少嵌套;逻辑上并列的分支仍使用else。
- 卫语句:将异常/错误分支前置,判断不通过时直接return,以降低圈复杂度(即最大嵌套层数)。
- 单个方法/函数逻辑中专用的多分支逻辑或复杂的多分支逻辑使用switch-case,多个方法/函数通用的简单多分支逻辑使用表驱动法。
- 表驱动:用hash表存储一组键值对映射,根据入参匹配key来获取value使用,以代替等值匹配的多条件分支。
- 拼接字符串的逻辑不复杂时,推荐使用+而不是fmt库方法。
- 能提前确定size时,调用make创建map/slice时直接传入size,此时slice通过索引赋值元素。
- 函数/方法传参/返回struct尽量用指针类型,slice/map则不要用指针类型。
- 在函数/方法的返回值列表中声明的非基本类型参数不会做初始化(因为只是声明),所以map/slice/chan/*struct类型需要在函数体内手动赋初值。
- 使用工具库flex简化代码并高效实现逻辑。
并发安全
- 在无法捕获panic的协程中做类型断言时,使用第二个参数来预防panic。
- 可能出现并发读写map的场景,使用sync.Map/sync.Mutex/chan来防止panic。
- 使用细粒度的数据操作控制来预防并发写导致的数据冲突,同时适用于缓存和数据库。
- 多实例场景下仅需要单次执行的操作,通过分布式锁使后访问数据的服务实例跳过操作。
- 只在逻辑处理有必要异步的场景下使用go协程,以避免带来额外的维护复杂度。
示例项目
这里提供了不同语言的参考示例项目,可以直接作为搭建新的微服务项目的代码模板使用,同时也欢迎贡献未收录编程语言的示例项目。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。