整潔架構是現現在是很是知名的架構了。然而咱們也許並不太清楚實現的細節。 所以我試着創造一個有着整潔架構的使用 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
整潔架構有一些概念性的層次,以下所示: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 服務也應該在接口層。在目錄 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、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。