一個使用 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:一些第三方包目錄
整潔架構有一些概念性的層次,如下所示:
一共有 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 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。