Go 语言的整洁架构之道

一个使用 gRPC 的 Go 项目整洁架构例子

我想告诉你的

整洁架构是现如今是非常知名的架构了。然而我们也许并不太清楚实现的细节。
因此我试着创造一个整洁架构的使用 gRPC 的 Go 项目。

这个小巧的项目是个用户注册的例子。请随意回应任何事情。

结构

8am 基于整洁架构,项目结构如下。

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

最外层目录包括三个文件夹:

  • app:应用包根目录
  • cmd:主包目录
  • vendor:一些供应商的包目录

整洁架构有一些概念性的层次,如下所示:

一共有 4 层,从外到内分别是蓝色,绿色,红色和黄色。我把应用目录表示为除了蓝色之外的三种颜色:

  • 接口:绿色层
  • 用例:红色层
  • 领域:黄色层

整洁架构最重要的就是让接口穿过每一层。

实体 — 黄色层

在我看来, 实体层就像是分层架构里的领域层。
因此为了避变和领域驱动设计里的实体概念弄混,我把这一层叫做应用/领域层。

应用/领域包括三个包:

  • 模型:包含聚合,实体和值对象
  • 存储库:包含聚合对象的仓库接口
  • 服务:包括依赖模型的应用服务

我将会解释每一个包的实现细节。

模型

模型包含如下用户聚合:

这并不是真正的聚合,但是我希望你们可以将来在本地运行的时候,加入各种各样的实体和值对象。

  1. package model
  2. type User struct {
  3. id string
  4. email string
  5. }
  6. func NewUser(id, email string) *User {
  7. return &User{
  8. id: id,
  9. email: email,
  10. }
  11. }
  12. func (u *User) GetID() string {
  13. return u.id
  14. }
  15. func (u *User) GetEmail() string {
  16. return u.email
  17. }

聚合就是一个事务的边界,这个事务是用来保证业务规则的一致性。因此,一个存储库就对应着一个聚合。

存储库

在这一层,存储库应该只是接口,因为它不应该知晓持久化的实现细节。而且持久化也是这一层的非常重要的精髓。

用户聚合存储的实现如下:

  1. package repository
  2. import "github.com/hatajoe/8am/app/domain/model"
  3. type UserRepository interface {
  4. FindAll() ([]*model.User, error)
  5. FindByEmail(email string) (*model.User, error)
  6. Save(*model.User) error
  7. }

FindAll 获取了系统里所有保存的用户。Save 则是把用户保存到系统中。我再次强调,这一层不应该知道对象被保存或者序列化到哪里了。

服务

服务层是不应该包括在模型层的业务逻辑的集合。 举个例子,该应用不允许任何已经存在的邮箱地址注册。如果这个验证在模型层做,我们就发现如下的错误:

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

Duplicated 函数User模型没有关联。
为了解决这个问题,我们可以增加服务层,如下所示:

  1. type UserService struct {
  2. repo repository.UserRepository
  3. }
  4. func (s *UserService) Duplicated(email string) error {
  5. user, err := s.repo.FindByEmail(email)
  6. if user != nil {
  7. return fmt.Errorf("%s already exists", email)
  8. }
  9. if err != nil {
  10. return err
  11. }
  12. return nil
  13. }

实体包括业务逻辑和穿过其他层的接口。
业务逻辑应在包含在模型和服务中,并且不应该依赖其他层。如果我们需要访问其他层,我们需要通过存储库接口。通过这样反转依赖,我们可以使这些包更加隔离,更加易于测试和维护。

用例 — 红色层

用例是应用一次操作的单位。在 8am 中,列出用户和注册用户就是两个用例。这些用例的接口表示如下:

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

为什么是接口?因为这些用例是在接口层 — 绿色层被使用。在跨层的时候,我们都应该定义成接口。

UserUsecase 简单实现如下:

  1. type userUsecase struct {
  2. repo repository.UserRepository
  3. service *service.UserService
  4. }
  5. func NewUserUsecase(repo repository.UserRepository, service *service.UserService) *userUsecase {
  6. return &userUsecase {
  7. repo: repo,
  8. service: service,
  9. }
  10. }
  11. func (u *userUsecase) ListUser() ([]*User, error) {
  12. users, err := u.repo.FindAll()
  13. if err != nil {
  14. return nil, err
  15. }
  16. return toUser(users), nil
  17. }
  18. func (u *userUsecase) RegisterUser(email string) error {
  19. uid, err := uuid.NewRandom()
  20. if err != nil {
  21. return err
  22. }
  23. if err := u.service.Duplicated(email); err != nil {
  24. return err
  25. }
  26. user := model.NewUser(uid.String(), email)
  27. if err := u.repo.Save(user); err != nil {
  28. return err
  29. }
  30. return nil
  31. }

userUsercase 依赖两个包。UserRepository 接口和 service.UserService 结构体。当使用者初始化用例时,这两个包必须被注入。通常这些依赖都是通过依赖注入容器解决,这个后文会提到。

列出用户这个用例会取到所有已经注册的用户,注册用户用例是如果同样的邮箱地址没有被注册的话,就用该邮箱把新用户注册到系统。

有一点要注意, User 不同于 model.User。model.User 也许包含很多业务逻辑,但是其他层最好不要知道这些具体逻辑。所以我为用例 users 定义了 DAO 来封装这些业务逻辑。

  1. type User struct {
  2. ID string
  3. Email string
  4. }
  5. func toUser(users []*model.User) []*User {
  6. res := make([]*User, len(users))
  7. for i, user := range users {
  8. res[i] = &User{
  9. ID: user.GetID(),
  10. Email: user.GetEmail(),
  11. }
  12. }
  13. return res
  14. }

所以,为什么服务是具体实现而不是接口呢?因为服务不依赖于其他层。相反的,存储库贯穿了其他层,并且它的实现依赖于其他层不应该知道的设备细节,因此它被定义为接口。我认为这是正解架构中最重要的事情了。

接口 — 绿色层

这一层放置的都是操作API接口,关系型数据库的存储库或者其他接口的边界的具体对象。在本例中,我加了两个具体物件,内存存取器和 gRPC 服务。

内存存取器

我加了具体用户存储库作为内存存取器。

  1. type userRepository struct {
  2. mu *sync.Mutex
  3. users map[string]*User
  4. }
  5. func NewUserRepository() *userRepository {
  6. return &userRepository{
  7. mu: &sync.Mutex{},
  8. users: map[string]*User{},
  9. }
  10. }
  11. func (r *userRepository) FindAll() ([]*model.User, error) {
  12. r.mu.Lock()
  13. defer r.mu.Unlock()
  14. users := make([]*model.User, len(r.users))
  15. i := 0
  16. for _, user := range r.users {
  17. users[i] = model.NewUser(user.ID, user.Email)
  18. i++
  19. }
  20. return users, nil
  21. }
  22. func (r *userRepository) FindByEmail(email string) (*model.User, error) {
  23. r.mu.Lock()
  24. defer r.mu.Unlock()
  25. for _, user := range r.users {
  26. if user.Email == email {
  27. return model.NewUser(user.ID, user.Email), nil
  28. }
  29. }
  30. return nil, nil
  31. }
  32. func (r *userRepository) Save(user *model.User) error {
  33. r.mu.Lock()
  34. defer r.mu.Unlock()
  35. r.users[user.GetID()] = &User{
  36. ID: user.GetID(),
  37. Email: user.GetEmail(),
  38. }
  39. return nil
  40. }

这是存储库的具体实现。如果我们想要把用户保存到数据库或者其他地方的话,需要实现一个新的存储库。尽管如此,我们也不需要修改模型层。这太神奇了。

User 只在这个包里定义。这也是为了解决不同层之间解封业务逻辑的问题。

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

gRPC 服务

我认为 gRPC 服务也应该在接口层。在目录 app/interface/rpc 下可以看到:

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

protocol 文件夹包含了协议缓存 DSL 文件 (user_service.proto) 和 生成的 RPC 服务
代码 (user_service.pb.go)。

user_service.go 是 gRPC 的端点处理程序的封装:

  1. type userService struct {
  2. userUsecase usecase.UserUsecase
  3. }
  4. func NewUserService(userUsecase usecase.UserUsecase) *userService {
  5. return &userService{
  6. userUsecase: userUsecase,
  7. }
  8. }
  9. func (s *userService) ListUser(ctx context.Context, in *protocol.ListUserRequestType) (*protocol.ListUserResponseType, error) {
  10. users, err := s.userUsecase.ListUser()
  11. if err != nil {
  12. return nil, err
  13. }
  14. res := &protocol.ListUserResponseType{
  15. Users: toUser(users),
  16. }
  17. return res, nil
  18. }
  19. func (s *userService) RegisterUser(ctx context.Context, in *protocol.RegisterUserRequestType) (*protocol.RegisterUserResponseType, error) {
  20. if err := s.userUsecase.RegisterUser(in.GetEmail()); err != nil {
  21. return &protocol.RegisterUserResponseType{}, err
  22. }
  23. return &protocol.RegisterUserResponseType{}, nil
  24. }
  25. func toUser(users []*usecase.User) []*protocol.User {
  26. res := make([]*protocol.User, len(users))
  27. for i, user := range users {
  28. res[i] = &protocol.User{
  29. Id: user.ID,
  30. Email: user.Email,
  31. }
  32. }
  33. return res
  34. }

userService 仅依赖用例接口。
如果你想使用其它层 (如:GUI) 的用例,你可以按照你的方式实现这个接口。

v1.go 是使用依赖注入容器的对象依赖性解析器:

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

v1.go 把从 registry.Container 取回的包应用在 gRPC 服务上。

最后,让我们看看依赖注入容器的实现。

注册

注册是解决对象依赖性的依赖注入容器。
我用的依赖注入容器是 github.com/sarulabs/di 。

sarulabs/di: go (golang) 的依赖注入容器。 请注册 GitHub 账号来为 sarulabs/di 开发贡献

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

  1. type Container struct {
  2. ctn di.Container
  3. }
  4. func NewContainer() (*Container, error) {
  5. builder, err := di.NewBuilder()
  6. if err != nil {
  7. return nil, err
  8. }
  9. if err := builder.Add([]di.Def{
  10. {
  11. Name: "user-usecase",
  12. Build: buildUserUsecase,
  13. },
  14. }...); err != nil {
  15. return nil, err
  16. }
  17. return &Container{
  18. ctn: builder.Build(),
  19. }, nil
  20. }
  21. func (c *Container) Resolve(name string) interface{} {
  22. return c.ctn.Get(name)
  23. }
  24. func (c *Container) Clean() error {
  25. return c.ctn.Clean()
  26. }
  27. func buildUserUsecase(ctn di.Container) (interface{}, error) {
  28. repo := memory.NewUserRepository()
  29. service := service.NewUserService(repo)
  30. return usecase.NewUserUsecase(repo, service), nil
  31. }

在上面的例子里,我用 buildUserUsecase 函数把字符串 user-usecase 和具体的用例实现联系起来。这样我们只要在一个地方注册,就可以替换掉任何用例的具体实现。


感谢你读完了这篇入门。欢迎提出宝贵意见。如果你有任何想法和改进建议,请不吝赐教!

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

ft_authoradmin  ft_create_time2018-10-15 13:57
 ft_update_time2018-10-15 13:57