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

prepeace發表於2019-03-03

一個使用 gRPC 的 Go 專案整潔架構例子

我想告訴你的是

整潔架構是現如今是非常知名的架構了。然而我們也許並不太清楚實現的細節。
因此我試著創造一個有著整潔架構的使用 gRPC 的 Go 專案。

這個小巧的專案是個使用者註冊的例子。請隨意在本文下面回覆。

結構

8am 基於整潔架構,專案結構如下。

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

最外層目錄包括三個資料夾:

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

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

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

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

  • 介面:綠色層
  • 用例:紅色層
  • 領域:黃色層

整潔架構最重要的就是讓介面穿過每一層。

實體 — 黃色層

在我看來, 實體層就像是分層架構裡的領域層。
因此為了避變和領域驅動設計裡的實體概念弄混,我把這一層叫做應用/領域層。

應用/領域包括三個包:

  • 模型:包含聚合,實體和值物件
  • 儲存庫:包含聚合物件的倉庫介面
  • 服務:包括依賴模型的應用服務

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

模型

模型包含如下使用者聚合:

這並不是真正的聚合,但是我希望你們可以將來在本地執行的時候,加入各種各樣的實體和值物件。

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章