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 :第三方包目录
整洁架构的概念分层如下图所示: 整个架构共有四层,从外到内依次为蓝绿红黄层,除了作为应用目录的蓝色层,其余各层分别表示了:
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 modeltype 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 repositoryimport "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 { }
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 函数与具体用例实现关联起来。由此,我们可以在注册文件中任意替换用例的具体实现。