[譯] Go 語言的整潔架構之道 —— 一個使用 gRPC 的 Go 專案整潔架構例子
整潔架構是現如今是非常知名的架構了。然而我們也許並不太清楚實現的細節。 因此我試著創造一個有著整潔架構的使用 RPC/">gRPC 的 Go 專案。
- ofollow,noindex">hatajoe/8am: Contribute to hatajoe/8am development by creating an account on GitHub.
這個小巧的專案是個使用者註冊的例子。請隨意在本文下面回覆。
結構
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:一些第三方包目錄
整潔架構有一些概念性的層次,如下所示:

一共有 4 層,從外到內分別是藍色,綠色,紅色和黃色。我把應用目錄表示為除了藍色之外的三種顏色:
- 介面:綠色層
- 用例:紅色層
- 領域:黃色層
整潔架構最重要的就是讓介面穿過每一層。
實體 — 黃色層
在我看來, 實體層就像是分層架構裡的領域層。 因此為了避變和領域驅動設計裡的實體概念弄混,我把這一層叫做應用/領域層。
應用/領域包括三個包:
- 模型:包含聚合,實體和值物件
- 儲存庫:包含聚合物件的倉庫介面
- 服務:包括依賴模型的應用服務
我將會解釋每一個包的實現細節。
模型
模型包含如下使用者聚合:
這並不是真正的聚合,但是我希望你們可以將來在本地執行的時候,加入各種各樣的實體和值物件。
package model type User struct { idstring 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 { reporepository.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 { IDstring 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 { IDstring 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 服務上。
最後,讓我們看看依賴注入容器的實現。
註冊
註冊是解決物件依賴性的依賴注入容器。 我用的依賴注入容器是 sarulabs%2Fdi%25E3%2580%2582" rel="nofollow,noindex">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 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為掘金 上的英文分享文章。內容覆蓋 Android 、 iOS 、 前端 、 後端 、 區塊鏈 、 產品 、 設計 、 人工智慧 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃 、官方微博、 知乎專欄 。