0%

Go 整洁架构实践

Date Log
17/09/2018 初始版本.

原文

Clean Architecture in Go

本文讲述了一个使用 Go 和 gRPC 实践整洁架构的案例。

前言

整洁架构现在已为人熟知,但是很多人可能并不了解如何去实现。本文尝试使用 Go 和 Grpc 提供一种清晰明了的实现方法。文中案例源码已放在基站,这个小项目演示了用户注册业务的实现,有任何问题可以随时反馈。

结构

8am 基于整洁架构,其目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
% tree
.
├── Makefile
├── README.md
├── app
│ ├── domain
│ │ ├── model
│ │ ├── repository
│ │ └── service
│ ├── interface
│ │ ├── persistence
│ │ └── rpc
│ ├── registry
│ └── usecase
├── cmd
│ └── 8am
│ └── main.go
└── vendor
├── vendor packages
|...

顶级目录包含了三个子目录,分别是:

  • app : 应用包根目录
  • cmd:main 包目录
  • vendor :第三方包目录

整洁架构的概念分层如下图所示:
image.png
整个架构共有四层,从外到内依次为蓝绿红黄层,除了作为应用目录的蓝色层,其余各层分别表示了:

  • interface: 绿色层
  • usercase: 红色层
  • domain: 黄色层

整洁架构的核心就是在各层之间构建接口。

实体层-黄色层

在作者看来,实体层和领域层在分层架构中的含义是类似的。这里称之为领域层是为了避免和 DDD 中实体的概念混淆。

领域层包含三个包:

  • model: 具有聚合,实体和值对象
  • repository: 具有聚合的存储库接口
  • service: 具有依赖于多个模型的应用程序服务

下面介绍各个包的实现细节:

model

model 描述的用户聚合如下:

这实际上还不是一个聚合,在这里视为聚合的前提是未来还会添加更多实体和值对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package model

type User struct {
id string
email string
}

func NewUser(id, email string) *User {
return &User{
id: id,
email: email,
}
}

func (u *User) GetID() string {
return u.id
}

func (u *User) GetEmail() string {
return u.email
}

聚合是事务的边界,用以保持业务规则的一致性。因此,需要有一个仓储对应一个聚合。

repository

repository 只负责提供实体集合的操作接口而不必关心持久化的具体实现。其代码实现如下:

1
2
3
4
5
6
7
8
9
package repository

import "github.com/hatajoe/8am/app/domain/model"

type UserRepository interface {
FindAll() ([]*model.User, error)
FindByEmail(email string) (*model.User, error)
Save(*model.User) error
}

FindAll 方法获取所有存储在系统中的用户。Save 方法保存用户。再次强调,该层不应该获知对象是怎样被存储或序列化的。

service

service 由各种业务逻辑组成。业务逻辑不应该包含在 model 中。例如,应用不允许已经存在的邮箱重复进行注册。如果把校验逻辑放在 model 中,会感到很别扭,如下:

1
2
3
func (u *User) Duplicated(email string) bool {
// Find user by email from persistence layer...
}

Duplicated function 实际上和 User 模型是无关的。
为了解决这个问题,我们可以添加如下的服务层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type UserService struct {
repo repository.UserRepository
}

func (s *UserService) Duplicated(email string) error {
user, err := s.repo.FindByEmail(email)
if user != nil {
return fmt.Errorf("%s already exists", email)
}
if err != nil {
return err
}
return nil
}

实体层包含业务逻辑以及与其它层的接口。业务逻辑仅应涉及 model 和 service, 而不应该依赖于其它任何层级。如果需要访问其它层,则应当使用 repository 穿透层级。通过这种依赖倒置的形式,各个包之间会有更好的隔离性,并且更方便测试和维护。

用例层-红色层

用例指的是应用的单一可操作单元。在该项目中, 获取用户列表和注册新用户被定义为用例。这些用例使用如下接口表示:

1
2
3
4
type UserUsecase interface {
ListUser() ([]*User, error)
RegisterUser(email string) error
}

为什么使用接口表示呢?这是因为用例将会被接口层(绿色层)使用。跨越层级的操作应通过定义的接口完成。

UserUsecase 的简单实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type userUsecase struct {
repo repository.UserRepository
service *service.UserService
}

func NewUserUsecase(repo repository.UserRepository, service *service.UserService) *userUsecase {
return &userUsecase {
repo: repo,
service: service,
}
}

func (u *userUsecase) ListUser() ([]*User, error) {
users, err := u.repo.FindAll()
if err != nil {
return nil, err
}
return toUser(users), nil
}

func (u *userUsecase) RegisterUser(email string) error {
uid, err := uuid.NewRandom()
if err != nil {
return err
}
if err := u.service.Duplicated(email); err != nil {
return err
}
user := model.NewUser(uid.String(), email)
if err := u.repo.Save(user); err != nil {
return err
}
return nil
}

userUsercase 依赖两个包: repository.UserRepository 接口 和 *service.UserService 结构。这两个包必须在用例初始化的时候由用例使用者进行注入。这些依赖关系通常由依赖注入容器解决,文中后面将会提及。ListUser 会获取所有已注册用户,RegisterUser 会将注册邮箱未重复的用户注册到系统中。

有一点要指出,这里的 User 并不是 model.User。model.User 可能拥有很多商业信息,但是其它层级不应该了解太多。 因此,这里专门为用例中的用户定义了 DAO 来屏蔽更具体的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type User struct {
ID string
Email string
}

func toUser(users []*model.User) []*User {
res := make([]*User, len(users))
for i, user := range users {
res[i] = &User{
ID: user.GetID(),
Email: user.GetEmail(),
}
}
return res
}

你可能会想,为什么这个服务不使用接口而是直接实现呢?这是因为该服务不依赖于任何其它服务。
相反,当仓储穿透层和具体实现依赖于其它层不应知道太多细节的设备时,就需要定义一个接口来实现。作者认为这是整洁架构中最重要的一点。

接口层-绿色层

该层放置具体对象,如 API 端点的处理程序,RDB 的存储库或接口的其他边界。在这个案例中,添加了内存存储访问器和 gRPC 服务两个对象。

内存存储访问器

作者使用用户存储库作为内存存储访问器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type userRepository struct {
mu *sync.Mutex
users map[string]*User
}

func NewUserRepository() *userRepository {
return &userRepository{
mu: &sync.Mutex{},
users: map[string]*User{},
}
}

func (r *userRepository) FindAll() ([]*model.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
users := make([]*model.User, len(r.users))
i := 0
for _, user := range r.users {
users[i] = model.NewUser(user.ID, user.Email)
i++
}
return users, nil
}

func (r *userRepository) FindByEmail(email string) (*model.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, user := range r.users {
if user.Email == email {
return model.NewUser(user.ID, user.Email), nil
}
}
return nil, nil
}

func (r *userRepository) Save(user *model.User) error {
r.mu.Lock()
defer r.mu.Unlock()
r.users[user.GetID()] = &User{
ID: user.GetID(),
Email: user.GetEmail(),
}
return nil
}

这是存储库的具体实现。如果我们需要将用户持久保存到 RDB 或其他,我们将需要另一个实现。但即使在这种情况下,我们也不需要更改模型层。模型层仅依赖于存储库接口,并对实现细节毫不关心。这很鹅妹子嘤。
这里的 User 仅在当前包中定义,用于实现跨越层级的信息解封。

1
2
3
4
type User struct {
ID string
Email string
}

gRPC service

作者认为 gRPC 服务也应当包括在接口层中。gRPC 服务的目录结构如下:

1
2
3
4
5
6
7
8
9
% tree
.
├── rpc.go
└── v1.0
├── protocol
│ ├── user_service.pb.go
│ └── user_service.proto
├── user_service.go
└── v1.go

protocol 目录包含协议缓冲区 DSL 文件(user_service.proto)和生成的 RPC 服务代码(user_service.pb.go)。
user_service.go 是 gRPC 端点处理程序的包装器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type userService struct {
userUsecase usecase.UserUsecase
}
func NewUserService(userUsecase usecase.UserUsecase) *userService {
return &userService{
userUsecase: userUsecase,
}
}

func (s *userService) ListUser(ctx context.Context, in *protocol.ListUserRequestType) (*protocol.ListUserResponseType, error) {
users, err := s.userUsecase.ListUser()
if err != nil {
return nil, err
}
res := &protocol.ListUserResponseType{
Users: toUser(users),
}
return res, nil
}

func (s *userService) RegisterUser(ctx context.Context, in *protocol.RegisterUserRequestType) (*protocol.RegisterUserResponseType, error) {
if err := s.userUsecase.RegisterUser(in.GetEmail()); err != nil {
return &protocol.RegisterUserResponseType{}, err
}
return &protocol.RegisterUserResponseType{}, nil
}

func toUser(users []*usecase.User) []*protocol.User {
res := make([]*protocol.User, len(users))
for i, user := range users {
res[i] = &protocol.User{
Id: user.ID,
Email: user.Email,
}
}
return res
}

userService 仅依赖用例接口。如果你想在其它层级(例如,终端)中使用用例,你可以在接口层中按照需求实现该服务。
v1.go 使用 DI 容器解析对象依赖项:

1
2
3
func Apply(server *grpc.Server, ctn *registry.Container) {
protocol.RegisterUserServiceServer(server, NewUserService(ctn.Resolve("user-usecase").(usecase.UserUsecase)))
}

v1.go 将从* registry.Container 检索到的包应用于 gRPC 服务。

最后,简单看一下 DI 容器的实现。

registry

registry 是一个 DI 容器用以解析对象依赖。这里使用了 github.com/sarulabs/di 作为 DI 容器。

github.com/surulabs/di 使用起来很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type Container struct {
ctn di.Container
}

func NewContainer() (*Container, error) {
builder, err := di.NewBuilder()
if err != nil {
return nil, err
}
if err := builder.Add([]di.Def{
{
Name: "user-usecase",
Build: buildUserUsecase,
},
}...); err != nil {
return nil, err
}
return &Container{
ctn: builder.Build(),
}, nil
}

func (c *Container) Resolve(name string) interface{} {
return c.ctn.Get(name)
}

func (c *Container) Clean() error {
return c.ctn.Clean()
}

func buildUserUsecase(ctn di.Container) (interface{}, error) {
repo := memory.NewUserRepository()
service := service.NewUserService(repo)
return usecase.NewUserUsecase(repo, service), nil
}

例如,在上面,将 user-usecase 字符串通过 buildUserUsecase 函数与具体用例实现关联起来。由此,我们可以在注册文件中任意替换用例的具体实现。

本文到此结束  感谢您的阅读