[譯] Go 語言的整潔架構之道 —— 一個使用 gRPC 的 Go 項目整潔架構例子

一個使用 gRPC 的 Go 項目整潔架構例子

我想告訴你的是

整潔架構是現現在是很是知名的架構了。然而咱們也許並不太清楚實現的細節。 所以我試着創造一個有着整潔架構的使用 gRPC 的 Go 項目。前端

這個小巧的項目是個用戶註冊的例子。請隨意在本文下面回覆。android

結構

8am 基於整潔架構,項目結構以下。ios

% tree
.
├── Makefile
├── README.md
├── app
│   ├── domain
│   │   ├── model
│   │   ├── repository
│   │   └── service
│   ├── interface
│   │   ├── persistence
│   │   └── rpc
│   ├── registry
│   └── usecase
├── cmd
│   └── 8am
│       └── main.go
└── vendor
    ├── vendor packages
    |...
複製代碼

最外層目錄包括三個文件夾:git

  • app:應用包根目錄
  • cmd:主包目錄
  • vendor:一些第三方包目錄

整潔架構有一些概念性的層次,以下所示:github

一共有 4 層,從外到內分別是藍色,綠色,紅色和黃色。我把應用目錄表示爲除了藍色以外的三種顏色:golang

  • 接口:綠色層
  • 用例:紅色層
  • 領域:黃色層

整潔架構最重要的就是讓接口穿過每一層。數據庫

實體 — 黃色層

在我看來, 實體層就像是分層架構裏的領域層。 所以爲了避變和領域驅動設計裏的實體概念弄混,我把這一層叫作應用/領域層。後端

應用/領域包括三個包:緩存

  • 模型:包含聚合,實體和值對象
  • 存儲庫:包含聚合對象的倉庫接口
  • 服務:包括依賴模型的應用服務

我將會解釋每個包的實現細節。bash

模型

模型包含以下用戶聚合:

這並非真正的聚合,可是我但願大家能夠未來在本地運行的時候,加入各類各樣的實體和值對象。

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
}
複製代碼

聚合就是一個事務的邊界,這個事務是用來保證業務規則的一致性。所以,一個存儲庫就對應着一個聚合。

存儲庫

在這一層,存儲庫應該只是接口,由於它不該該知曉持久化的實現細節。並且持久化也是這一層的很是重要的精髓。

用戶聚合存儲的實現以下:

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 則是把用戶保存到系統中。我再次強調,這一層不該該知道對象被保存或者序列化到哪裏了。

服務

服務層是不該該包含在模型層中的業務邏輯集合。舉個例子,該應用不容許任何已經存在的郵箱地址註冊。若是這個驗證在模型層作,咱們就發現以下的錯誤:

func (u *User) Duplicated(email string) bool {
        // Find user by email from persistence layer...
}
複製代碼

Duplicated 函數User 模型沒有關聯。
爲了解決這個問題,咱們能夠增長服務層,以下所示:

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
}
複製代碼

實體包括業務邏輯和穿過其餘層的接口。 業務邏輯應該包含在模型和服務中,而且不該該依賴其餘層。若是咱們須要訪問其餘層,咱們須要經過存儲庫接口。經過這樣反轉依賴,咱們可使這些包更加隔離,更加易於測試和維護。

用例 —— 紅色層

用例是應用一次操做的單位。在 8am 中,列出用戶和註冊用戶就是兩個用例。這些用例的接口表示以下:

type UserUsecase interface {
    ListUser() ([]*User, error)
    RegisterUser(email string) error
}
複製代碼

爲何是接口?由於這些用例是在接口層 —— 綠色層被使用。在跨層的時候,咱們都應該定義成接口。

UserUsecase 簡單實現以下:

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 依賴兩個包。UserRepository 接口和 service.UserService 結構體。當使用者初始化用例時,這兩個包必須被注入。一般這些依賴都是經過依賴注入容器解決,這個後文會提到。

ListUser 這個用例會取到全部已經註冊的用戶,RegisterUser 用例是若是一樣的郵箱地址沒有被註冊的話,就用該郵箱把新用戶註冊到系統。

有一點要注意,User 不一樣於 model.User. model.User 也許包含不少業務邏輯,可是其餘層最好不要知道這些具體邏輯。因此我爲用例 users 定義了 DAO 來封裝這些業務邏輯。

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 接口,關係型數據庫的存儲庫或者其餘接口的邊界的具體對象。在本例中,我加了兩個具體物件,內存存取器和 gRPC 服務。

內存存取器

我加了具體用戶存儲庫做爲內存存取器。

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
}
複製代碼

這是存儲庫的具體實現。若是咱們想要把用戶保存到數據庫或者其餘地方的話,須要實現一個新的存儲庫。儘管如此,咱們也不須要修改模型層。這太神奇了。

User 只在這個包裏定義。這也是爲了解決不一樣層之間解封業務邏輯的問題。

type User struct {
    ID    string
    Email string
}
複製代碼

gRPC 服務

我認爲 gRPC 服務也應該在接口層。在目錄 app/interface/rpc 下能夠看到:

% 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 的端點處理程序的封裝:

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 僅依賴用例接口。
若是你想使用其它層(如:GUI)的用例,你能夠按照你的方式實現這個接口。

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

func Apply(server *grpc.Server, ctn *registry.Container) {
    protocol.RegisterUserServiceServer(server, NewUserService(ctn.Resolve("user-usecase").(usecase.UserUsecase)))
}
複製代碼

v1.go 把從 registry.Container 取回的包應用在 gRPC 服務上。

最後,讓咱們看看依賴注入容器的實現。

註冊

註冊是解決對象依賴性的依賴注入容器。 我用的依賴注入容器是 github.com/sarulabs/di…

sarulabs/di: go (golang) 的依賴注入容器。請註冊 GitHub 帳號來爲 sarulabs/di 開發作貢獻

github.com/surulabs/di 能夠被這樣簡單的使用:

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
}
複製代碼

在上面的例子裏,我用 buildUserUsecase 函數把字符串 user-usecase 和具體的用例實現聯繫起來。這樣咱們只要在一個地方註冊,就能夠替換掉任何用例的具體實現。


感謝你讀完了這篇入門。歡迎提出寶貴意見。若是你有任何想法和改進建議,請不吝賜教!

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索