Go工程化--项目重构
1.前言
前面的章节讲述了项目结构
, 依赖注入
,API设计
,包管理
,单元测试
。。。基本上还是将工程化当中的大部分东西都讲到了。
结合前面文章中提到的各种知识来看一下如何将一个老的项目迁移到新的项目结构当中来,这里面的坑也非常的多。
2. 重构前
2.1 目录结构
你的目录结构可能是这样的。
├── app
│ ├── controller
│ ├── lib
│ ├── middleware
│ ├── model
│ └── router
├── cmd
│ ├── cron
│ └── server
├── config
│ └── initializer
├── db
│ └── migrate
│ └── migrate.go
├── go.mod
├── mock
├── test
└── third_party
这个结构是仿照 rails 框架设计而来的,这个结构在很长一段时间都是挺好用的,大概解释一下每个目录的含义:
- app: 应用逻辑相关的代码都在这里
- controller: 控制器层,主要负责路由
- lib: 一些工具库函数
- middleware: 路由中间件
- router: 路由注册
- model: 由于我们使用的是充血模型,所以这一层的内容比较多,包含了领域对象,业务逻辑,数据存储等都在这里
- cmd: 二进制文件目录
- cron: 定时任务
- server: http server 服务
- config: 配置文件目录
- initializer: 初始化
- db: migration 目录
- mock: gomock 生成的文件
- test: 测试工具库
- third_party: 第三方文件
各个模块的调用关系
2.2 存在的问题
2.2.1 单元测试覆盖率
单元测试覆盖率基本只能达到 80%左右,很多代码借本就不可测试,具体有以下原因
-
cmd
- 之前在这一层当中最大的问题就是我们手写了大量启动代码,全手动依赖注入,还有一些隐式的依赖,这就导致在项目后期的时候启动的代码已经非常长了,而且很容易遗漏依赖关系的处理,建议如果不是特别小的项目还是使用 wire 比较香。
-
initializer
- 前所有的初始化都在这个包内完成,这个不是太大的问题,但是要命的是这里面有大量的全局变量,各种各样,过多的全局变量导致单元测试非常难写。
-
model
- 这一层的事情特别多,业务逻辑,领域对象,持久化存储等都在这一层完成,这就导致了后面我们 model 层的代码特别多,变成了一团乱麻,真的是剪不断理还乱,慢慢的这一层的逻辑我自己都理不清了。
- 这一层的代码耦合也很严重,无论是什么定时任务还是消息队列还是 server 的代码都放到了这里,导致出现了很多坑,举个例子,可能我有一个函数是 GetArticles 这个函数最开始是为了 api 接口返回一些简要的列表数据,只需要查询一张表,返回速度非常快,但是我定时任务有一个地方也需要这个函数,然后我一看,真好这里已经有这个函数了我们就直接复用,但是数据内容不满足需求,我们就直接在这个函数当中加逻辑,然后需求满足了,过一段时间我们突然要求所有的接口必须要在 500ms 以内,结果发现坑来了,很多地方依赖这个函数,看 APM,就这一个函数的查询就需要耗费超过 500ms 🤣
-
controller
- 这一层主要做参数的处理还有部分业务逻辑
Controller
层和model
层的界定比较模糊,有的主要业务逻辑放到了 model 有的有部分业务逻辑又放到了 Controller 了,到后面就是一点一点的找问题
- 这一层主要做参数的处理还有部分业务逻辑
-
错误处理
- 之前在项目内统一了错误码,但是存在两个问题
- 一个是要么处处 wrap,要么忘记 wrap 没有一个统一准则,这就导致要么错误堆栈长的没法看,要么就没有太多的错误信息没法排查
- 另一个是我们之前在 Controller 层跑业务错误代码,这就导致了很多时候想要返回一些细节的错误信息就无能为力,即使在 model 层抛了也会被 Controller 层吞掉
-
接口文档
- 这也是一个很大的痛点,之前想了很多方式都不能很好的解决
- 之前我们的文档分布五花八门,最开始使用
gin swagger
通过写注释的方式来生成相关接口文档,说实话可以用但是比较难用,因为这个注释其实和代码逻辑是两套东西,相当于写一遍代码再写一遍注释,慢慢的就没有人写了,或者还有写的是错误的。最麻烦的是在方案设计阶段我们不会直接写代码注释,所以测试同学在写测试方案的时候会比较麻烦,也不符合我们后续技术方案评审的要求所以后面也就废弃了 - 后面我们写到内部的文档平台上,还不如之前的 swagger,虽然解决了方案阶段没接口文档的问题,但是接口文档总是在变化当中的,特别是在开发的时候,这就让前后端对接联调,以及测试同学测试的非常难受,特别是经常会出现和前端同学沟通好了但是忘记和测试说的情况。
3. 重构
迁移要三思,处处都是坑, 重构要小心,TDD 方式
3.1 重构后目录结构
为了能够更加直观,先来复习一下之前提到过的项目目录结构看一下重构前后有哪些不同
.
├── api
│ └── product
│ └── app
│ ├── errcode
│ └── v1
│ └── v1.proto
├── cmd
│ ├── cron
│ │ ├── main.go
│ │ └── wire.go
│ ├── migrate
│ └── server
├── config
├── go.mod
├── internal
│ ├── cron
│ │ ├── repo
│ │ ├── service
│ │ ├── usecase
│ │ └── wire_set.go
│ ├── domain
│ ├── pkg
│ │ ├── copier
│ │ ├── mock
│ │ └── test
│ └── server
│ ├── repo
│ ├── service
│ ├── usecase
│ └── wire_set.go
└── third_party
新的目录结构的调用链路
注意,在这里 service 和 usecase 实际调用的都是 domain 中的接口,只是 usecase 和 repo 实现这些接口而已,所以我们这里用虚线画出来
3.2重构原则
从各个模块来阐述我在重构这个项目的时候是怎么做的,每个模块都有每个模块自己的坑。开始之前呢我们先来看一下几个总的原则:
- 结构简单的应用优先
- 有充分的单元测试的应用优先
- 先写测试,测试需要在新老代码同时通过
3.2.1 api
api 当中主要写的就是 proto
文件,这个 proto
文件替代了我们在之前的 router
以及部分 controller
中的逻辑,定义了 proto 文件之后,生成的代码当中主要要完成的就是,路由的注册
,参数绑定
,返回值结构
填充。
3.2.2 背景
我们之前的项目采用的是 gin 作为路由框架,返回值采用下面这种统一的结构
{
"code": 1,
"msg": "成功",
"data": {}
}
在 api 层的路由注册,参数绑定,返回值结构填充我们都使用工具进行统一处理
3.2.3 一个例子
v1.proto
// 这里指定了 proto 文件的版本
syntax = "proto3";
// package 命名规则: product.application.version
package product.app.v1;
// go_package 生成 go 文件当中的包名
option go_package = "github.com/mohuishou/new-project/api/product/app/v1";
import "google/api/annotations.proto";
service BlogService {
// 创建文章
rpc CreateArticle(CreateArticleReq) returns (CreateArticleResp) {
// option 还是都加上,可以利用插件自动生成 swagger 文档
option (google.api.http) = {
post: "/article"
body: "*"
};
}
}
// 参数和返回值定义,这里就不详细列了
message CreateArticleReq {}
message CreateArticleResp {}
3.2.4 几个典型的坑
-
Get 请求参数如何进行绑定,默认无法修改 struct tag?
-
这个问题同样适用于参数校验,原来 gin 参数校验可以在
struct
上加tag
解决,这个有两种解决方案 -
一种是使用 protoc-go-inject-tag 加注释解决
-
另外一种是使用 gogo/protobuf 支持添加 option 的方式来添加 tag
-
第一种, 只需要在定义 message 的时候添加注释 // @inject_tag: 后面是具体的 tag
message GetArticleReq { // @inject_tag: form:"id" binding:"required" int32 id = 1; }
然后我们在生成好对应的 go 文件之后执行一下 protoc-go-inject-tag -input=filepath 就可以了
-
-
返回值是一个数组?
-
举个例子,我们之前可能有一个 get: /article/tags 的接口,由于一篇文章当中的标签不会很多,所以我们没有做分页,返回数据的时候直接在 data 里面塞了一个数组,然后迁移的时候就麻烦了
-
因为在
protobuf
的rpc方法定义当中,只能返回一个结构体,无法返回一个数组结构*,但是我们做重构的时候又不想有 api 的破坏性变更,因为这项所有的依赖方都需要进行修改成本太大了,那我们如何兼容呢? -
解决方案:·是先生成对应的 go 结构体,然后在同一个包内创建一个
xx_type.pb.go
里面实现 json 的解析接口,虽然定义的是一个结构体,但是返回接口数据的时候返回的是一个数组 -
举个例子,下面是我定义的 pb
message ListArticleTagsResp { repeated Tag tags = 1; } message Tag { string key = 1; string value = 2; }
然后生成了
v1.pb.go
,我创建了一个v1_type.pb.go
// UnmarshalJSON sets *m to a copy of data. func (m *ListArticleTagsResp) UnmarshalJSON(data []byte) error { if m == nil { return errors.New("json.RawMessage: UnmarshalJSON on nil pointer") } return json.Unmarshal(data, &m.Tags) } // MarshalJSON returns m as the JSON encoding of m. func (m *ListArticleTagsResp) MarshalJSON() ([]byte, error) { if m == nil { return []byte("null"), nil } return json.Marshal(m.Tags) }
重构的时候还是建议不要做破坏性的变动
-
-
返回值当中包含时间
- 除了数组之外,返回值当中包含时间也是挺麻烦的一件事情,首先 pb 的基础类型里面没有时间类型,然后 google 官方的库当中有一个 timestamp 包,可以使用,但是使用的时候就会发现,在 json 序列话的时候不是一个时间字段,而是一个对象值,和我们之前直接使用 time.Time 的行为不一致。
- 我的做法是仿照 google 的包自己搞一个然后实现 json 的相关方法,让 json 序列化的时候的行为和 time.Time 保持一致
- 首先定义
timestamp.proto
// 这里指定了 proto 文件的版本 syntax = "proto3"; // package 命名规则: product.application.version package product.app.v1; // go_package 生成 go 文件当中的包名 option go_package = "github.com/mohuishou/new-project/api/product/app/v1"; message Timestamp { // Represents seconds of UTC time since Unix epoch // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to // 9999-12-31T23:59:59Z inclusive. int64 seconds = 1; // Non-negative fractions of a second at nanosecond resolution. Negative // second values with fractions must still have non-negative nanos values // that count forward in time. Must be from 0 to 999,999,999 // inclusive. int32 nanos = 2; }
- 和上面一样,创建一个
timestamp_type.pb.go
,除了实现 json 的接口以外还实现了两个转换方法,用于 service 层调用
// NewTimestamp NewTimestamp func NewTimestamp(t time.Time) *Timestamp { return &Timestamp{ Seconds: t.Unix(), Nanos: int32(t.Nanosecond()), } } // Time 类型转换 func (m *Timestamp) Time() time.Time { // Don't return the zero value on error, because corresponds to a valid // timestamp. Instead return whatever time.Unix gives us. if m == nil { return time.Unix(0, 0).UTC() // treat nil like the empty Timestamp } return time.Unix(m.Seconds, int64(m.Nanos)).UTC() } // UnmarshalJSON sets *m to a copy of data. func (m *Timestamp) UnmarshalJSON(data []byte) error { if m == nil { return errors.New("json.RawMessage: UnmarshalJSON on nil pointer") } var t time.Time if err := json.Unmarshal(data, &t); err != nil { return err } *m = *NewTimestamp(t) return nil } // MarshalJSON returns m as the JSON encoding of m. func (m *Timestamp) MarshalJSON() ([]byte, error) { if m == nil { return []byte("null"), nil } return json.Marshal(m.Time()) }
3.2.5 domain
domain 这一层主要是包含 do 对象的定义,以及 usecase 和 repo 层的接口定义,由于我们现在使用的 gorm 所以,也会在这里给 do 对象加上一些 tag 用于标志索引,关联关系等。
domain example
// article.go
// Article 文章
type Article struct {
Model // 基础结构体,包含 id, created_at, deleted_at, updated_at
Title string `json:"title"`
Content string `json:"content"`
Tags []Tag `json:"tags" gorm:"many2many:article_tags"`
}
// IArticleUsecase IArticleUsecase
type IArticleUsecase interface {
// 获取文章详情
GetArticle(ctx context.Context, id int) (*Article, error)
// 创建一篇文章
CreateArticle(ctx context.Context, article *Article) error
}
// IArticleRepo IArticleRepo
type IArticleRepo interface {
GetArticle(ctx context.Context, id int) (*Article, error)
CreateArticle(ctx context.Context, article *Article) error
}
// tag.go
// Tag 标签数据
type Tag struct {
Model
Key string `json:"key"`
Value string `json:"value"`
}
3.2.6 小技巧: 批量 mock 接口
使用最新的项目结构我们会在 domain 中创建大量接口,之前在 单元测试 中提到了,在每一层的单元测试的时候,我们都会把依赖的接口用 gomock 给 mock 掉,让测试尽量轻量级一些,为了简化 gomock 的创建,我们可以在 makefile 当中写一个 shell 脚本,找出含有 interface 定义的文件,然后我们用 gomock 生成对应的 mock 文件
mockgen:
cd ./internal && for file in `egrep -rnl "type.*?interface" ./domain | grep -v "_test" `; do \
echo $$file ; \
cd .. && mockgen -destination="./internal/pkg/mock/$$file" -source="./internal/$$file" && cd ./internal ; \
done
3.2.7 service
新的 service 层的主要作用就是 dto 数据和 do 数据的相互转换,它实现了 v1 包中的相关接口,service 的代码比较简单,我们直接看一个例子
-
service example
在重构·service
、usecase
、repo
的时候,都应该先写对应的单元测试// 确保实现了对应的接口 var _ v1.BlogServiceHTTPServer = &Artcile{} // Artcile Artcile type Artcile struct { usecase domain.IArticleUsecase } // NewArticleService 初始化方法 func NewArticleService(usecase domain.IArticleUsecase) *Artcile { return &Artcile{usecase: usecase} } // CreateArticle 创建一篇文章 func (a *Artcile) CreateArticle(ctx context.Context, req *v1.CreateArticleReq) (*v1.CreateArticleResp, error) { article := &domain.Article{ Title: req.Title, Content: req.Content, } err := a.usecase.CreateArticle(ctx, article) return &v1.CreateArticleResp{}, err }
-
小技巧: copier 减少重复的复制粘贴操作
-
在上面的例子我们可以看到,我们的数据转换是手动写的,这种方法不是不行,但是示例当中的字段比较少,如果字段多了起来,并且还有各种数组类型的存在的时候,数据转换的这部分代码写的就会比较难受了,如果你的应用和我的一样对性能的要求不是很高的话可以试试下面这种方式。
-
可以使用 jinzhu 大佬的 copier 包来做的数据转换,但是这个包比较局限,它主要是在两个结构的之间的字段名以及类型相同的时候有用,向出现我们上面在 api
-
手动写了一个 copier 函数签名一样,实现非常简单,当然性能不太好,但是如果对性能要求不高的话也能用.
// Copy 从一个结构体复制到另一个结构体 func Copy(to, from interface{}) error { b, err := json.Marshal(from) if err != nil { return errors.Wrap(err, "marshal from data err") } err = json.Unmarshal(b, to) if err != nil { return errors.Wrap(err, "unmarshal to data err") } return nil }
如果使用这个函数的话,我们上面的代码就可以改成
// CreateArticle 创建一篇文章 func (a *Artcile) CreateArticle(ctx context.Context, req *v1.CreateArticleReq) (*v1.CreateArticleResp, error) { var article domain.Article err := copier.Copy(&article, req) if err != nil { return nil, err } err = a.usecase.CreateArticle(ctx, &article) return &v1.CreateArticleResp{}, err }
-
3.2.8 usecase
这一层主要是业务逻辑,业务逻辑相关代码都应该在这一层写,当然有时候我们的代码可能就只是保存一下数据没啥业务逻辑,可能是直接调用一下 repo 的方式
type article struct {
repo domain.IArticleRepo
}
// NewArticleUsecase init
func NewArticleUsecase(repo domain.IArticleRepo) domain.IArticleUsecase {
return &article{repo: repo}
}
func (u *article) GetArticle(ctx context.Context, id int) (*domain.Article, error) {
// 这里可能有其他业务逻辑...
return u.repo.GetArticle(ctx, id)
}
func (u *article) CreateArticle(ctx context.Context, article *domain.Article) error {
return u.repo.CreateArticle(ctx, article)
}
3.2.9 repo
这一层是数据持久层,像数据库存取,缓存的处理应该都在这一层做掉,还有可能后续我们变成调用一个微服务来实现,那么这个被调用的微服务也应该在这里做。
type article struct {
db *gorm.DB
}
// NewArticleRepo init
func NewArticleRepo(db *gorm.DB) domain.IArticleRepo {
return &article{db: db}
}
func (r *article) GetArticle(ctx context.Context, id int) (*domain.Article, error) {
var a domain.Article
if err := r.db.WithContext(ctx).Find(&a, id); err != nil {
// 这里返回业务错误码
}
return &a, nil
}
func (r *article) CreateArticle(ctx context.Context, article *domain.Article) error {
if err := r.db.WithContext(ctx).Create(article); err != nil {
// 这里返回业务错误码
}
return nil
}
3.2.9 依赖注入 wire_set.go
依赖注入一节讲到过,我们使用 wire 作为我们的依赖注入框架,由于 wire 不能出现相同的 Provider 所以我们会在 internal 的每个子目录下创建一下 wire_set.go 用于构建 wire.Set 结构,到时我们在 cmd 下直接应用这个文件的内容就可以了
package server
import (
"github.com/google/wire"
"github.com/mohuishou/new-project/internal/server/repo"
"github.com/mohuishou/new-project/internal/server/service"
"github.com/mohuishou/new-project/internal/server/usecase"
)
// Set for di
var Set = wire.NewSet(
service.NewArticleService,
usecase.NewArticleUsecase,
repo.NewArticleRepo,
)
3.2.10 cmd
cmd 下的二进制目录,我一般会包含四个文件
.
├── main.go # 包含 main 函数
├── server.go # 包含 wire set 等
├── wire.go # for wire build
└── wire_gen.go # wire 自动生成的
- server.go 在真实的项目当中我们 service 包下面一般会有多个 service 文件,对应不同的结构体,并且除了 internal 中的依赖外我们可能还会有很多公共的依赖,例如配置中心,日志,数据库等,我的习惯是构建一个新的结构,在这个结构当中我们把所有的注册还还有 wire.set 搞好,这样 main 函数就会很清爽,整体上也会比较整洁
var set = wire.NewSet( // domains server.Set, // common initializer.Set, ) type services struct { article *service.Artcile } func (s *services) register(r gin.IRouter) { v1.RegisterBlogServiceHTTPServer(r, s.article) }
- wire.go 经过 server.go 封装之后,wire.go 的代码就非常简单了
// NewServices NewServices func NewServices() (*services, error) { panic(wire.Build( wire.Struct(new(services), "*"), set, )) }
- main.go我这里还没有像 kratos 把程序的启动和退出封装起来,如果封装了会更加优雅一点
func main() { s, err := NewServices() if err != nil { panic(err) } e := gin.Default() s.register(e) // 这里还有优雅中止的一些代码 }
4. 总结
新的结构不仅仅进行了水平拆分 还按照功能进行了垂直拆分,将定时任务和 http 服务的代码拆分开来,整体的结构都清晰了很多,并且由于大量使用 依赖注入,所以代码的可测性非常的好,写单元测试非常容易。
但是这里也有一个坑,拆分的时候要注意,开始的时候拆分想按照 领域进行拆分,并且拆分的非常细,导致出现了很多服务,后面重新 review 了一下发现其实这些 服务边界 没有那么清晰,即使我们以后拆微服务,也不会把这些拆成两个不同的微服务,所以后面再改了一次才构成了现在的结构。所以我们在进行垂直拆分的时候一定要多问问自己,或者多和团队的同学讨论一下。
最后想要业务开发的比较开心愉快,那基础设施的建设非常重要,像本文提到的很多代码只要我们统一了规范和结构都可以通过工具来自动生成。