我使用Go和gRPC創建了一個微服務,並將程序設計和編程的最佳實踐應用於該項目。 我寫了一系列關於在項目工作中做出的設計決策和取舍的文章,此篇是關於程序設計。
程序的設計遵循清晰架構(Clean Architecture)¹。 業務邏輯代碼分三層:用例(usecase),域模型(model)和數據服務(dataservice)。
有三個頂級包“usecase”,“model”和“dataservice”,每層一個。 在每個頂級包(模型除外)中只有一個以該包命名的文件。 該文件為每個包定義了外部世界的接口。 從頂層向下的依賴結構層次是:“usecase”,“dataservice”和“model”。 上層包依賴於較低層的包,依賴關系永遠不會反向。
用例(usecase):
“usecase”是應用程序的入口點,本項目大部分業務邏輯都在用例層。 我從這篇文章²中獲得了部分業務邏輯思路。 有三個用例“registration”,“listUser”和“listCourse”。 每個用例都實現了一個業務功能。 用例可能與真實世界的用例不同,它們的創建是為了說明設計理念。 以下是注冊用例的接口:
// RegistrationUseCaseInterface is for users to register themselves to an application. It has registration related functions.
// ModifyAndUnregisterWithTx() is the one supporting transaction, the other are not.
type RegistrationUseCaseInterface interface {
// RegisterUser register a user to an application, basically save it to a database. The returned resultUser that has
// a Id ( auto generated by database) after persisted
RegisterUser(user *model.User) (resultUser *model.User, err error)
// UnregisterUser unregister a user from an application by user name, basically removing it from a database.
UnregisterUser(username string) error
// ModifyUser change user information based on the User.Id passed in.
ModifyUser(user *model.User) error
// ModifyAndUnregister change user information and then unregister the user based on the User.Id passed in.
// It is created to illustrate transaction, no real use.
ModifyAndUnregister(user *model.User) error
// ModifyAndUnregisterWithTx change user information and then unregister the user based on the User.Id passed in.
// It supports transaction
// It is created to illustrate transaction, no real use.
ModifyAndUnregisterWithTx(user *model.User) error
// EnableTx enable transaction support on use case. Need to be included for each use case needs transaction
// It replaces the underline database handler to sql.Tx for each data service that used by this use case
EnableTxer
}
“main”函數將通過此接口調用“用例”,該接口僅依賴於模型層。
以下是“registration.go”的部分代碼,它實現了“RegistrationUseCaseInterface”中的功能。 “RegistrationUseCase”是具體的結構。 它有兩個成員“UserDataInterface”和“TxDataInterface”。 “UserDataInterface”可用於調用數據服務層中的方法(例如“UserDataInterface.Insert(user)”)。 “TxDataInterface”用於實現事務。 它們的具體類型由應用程序容器(ApplicationContainer)創建,並通過依賴注入到每個函數中。 任何用例代碼僅依賴於數據服務接口,並不依賴於數據庫相關代碼(例如,sql.DB或sql.Stmt)。 任何數據庫訪問代碼都通過數據服務接口執行。
// RegistrationUseCase implements RegistrationUseCaseInterface.
// It has UserDataInterface, which can be used to access persistence layer
// TxDataInterface is needed to support transaction
type RegistrationUseCase struct {
UserDataInterface dataservice.UserDataInterface
TxDataInterface dataservice.TxDataInterface
}
func (ruc *RegistrationUseCase) RegisterUser(user *model.User) (*model.User, error) {
err := user.Validate()
if err != nil {
return nil, errors.Wrap(err, "user validation failed")
}
isDup, err := ruc.isDuplicate(user.Name)
if err != nil {
return nil, errors.Wrap(err, "")
}
if isDup {
return nil, errors.New("duplicate user for " + user.Name)
}
resultUser, err := ruc.UserDataInterface.Insert(user)
if err != nil {
return nil, errors.Wrap(err, "")
}
return resultUser, nil
}
通常一個用例可以具有一個或多個功能。 上面的代碼顯示了“RegisterUser”功能。 它首先檢查傳入的參數“user”是否有效,然后檢查用戶是否尚未注冊,最后調用數據服務層注冊用戶。
數據服務(Data service):
此層中的代碼負責直接數據庫訪問。 這是域模型“User”的數據持久層的接口。
// UserDataInterface represents interface for user data access through database
type UserDataInterface interface {
// Remove deletes a user by user name from database.
Remove(username string) (rowsAffected int64, err error)
// Find retrieves a user from database based on a user's id
Find(id int) (*model.User, error)
// FindByName retrieves a user from database by User.Name
FindByName(name string) (user *model.User, err error)
// FindAll retrieves all users from database as an array of user
FindAll() ([]model.User, error)
// Update changes user information on the User.Id passed in.
Update(user *model.User) (rowsAffected int64, err error)
// Insert adds a user to a database. The returned resultUser has a Id, which is auto generated by database
Insert(user *model.User) (resultUser *model.User, err error)
// Need to add this for transaction support
EnableTxer
}
以下是“UserDataInterface”中MySql實現“insert”功能的代碼。 這里我使用“gdbc.SqlGdbc”接口作為數據庫處理程序的封裝以支持事務。 “gdbc.SqlGdbc”接口的具體實現可以是sql.DB(不支持事務)或sql.Tx(支持事務)。 通過“UserDataSql”結構傳入函數作為接收者,使“Insert()”函數對事務變得透明。 在“insert”函數中,它首先從“UserDataSql”獲取數據庫鏈接,然后創建預處理語句(Prepared statement)並執行它; 最后它獲取插入的id並將其返回給調用函數。
// UserDataSql is the SQL implementation of UserDataInterface
type UserDataSql struct {
DB gdbc.SqlGdbc
}
func (uds *UserDataSql) Insert(user *model.User) (*model.User, error) {
stmt, err := uds.DB.Prepare(INSERT_USER)
if err != nil {
return nil, errors.Wrap(err, "")
}
defer stmt.Close()
res, err := stmt.Exec(user.Name, user.Department, user.Created)
if err != nil {
return nil, errors.Wrap(err, "")
}
id, err := res.LastInsertId()
if err != nil {
return nil, errors.Wrap(err, "")
}
user.Id = int(id)
logger.Log.Debug("user inserted:", user)
return user, nil
}
如果需要支持不同的數據庫,則每個數據庫都需要一個單獨的實現。 我將在另一篇文章“事務管理³中會詳細解釋。
域模型(Model):
模型是唯一沒有接口的程序層。 在Clean Architecture中,它被稱為“實體(Entity)”。 這是我偏離清晰架構的地方。 此應用程序中的模型層沒有太多業務邏輯,它只定義數據。 大多數業務邏輯都在“用例”層中。 根據我的經驗,由於延遲加載或其他原因,在執行用例時,大多數情況下域模型中的數據未完全加載,因此“用例”需要調用數據服務 從數據庫加載數據。 由於域模型不能調用數據服務,因此業務邏輯必須是在“用例”層。
數據校驗(Validation):
import (
"github.com/go-ozzo/ozzo-validation"
"time"
)
// User has a name, department and created date. Name and created are required, department is optional.
// Id is auto-generated by database after the user is persisted.
// json is for couchdb
type User struct {
Id int `json:"uid"`
Name string `json:"username"`
Department string `json:"department"`
Created time.Time `json:"created"`
}
// Validate validates a newly created user, which has not persisted to database yet, so Id is empty
func (u User) Validate() error {
return validation.ValidateStruct(&u,
validation.Field(&u.Name, validation.Required),
validation.Field(&u.Created, validation.Required))
}
//ValidatePersisted validate a user that has been persisted to database, basically Id is not empty
func (u User) ValidatePersisted() error {
return validation.ValidateStruct(&u,
validation.Field(&u.Id, validation.Required),
validation.Field(&u.Name, validation.Required),
validation.Field(&u.Created, validation.Required))
}
以上是域模型“User”的代碼,其中有簡單的數據校驗。將校驗邏輯放在模型層中是很自然的,模型層應該是應用程序中的最低層,因為其他層都依賴它。校驗規則通常只涉及低級別操作,因此不應導致任何依賴問題。此應用程序中使用的校驗庫是ozzo-validation⁴。它是基於接口的,減少了對代碼的干擾。請參閱GoLang中的輸入驗證⁵來比較不同的校驗庫。一個問題是“ozzo”依賴於“database/sql”包,因為支持SQL校驗,這搞砸了依賴關系。將來如果出現依賴問題,我們可能需要切換到不同的庫或刪除庫中的“sql”依賴項。
你可能會問為什么要將校驗邏輯放在域模型層中,而將業務邏輯放在“用例”層中?因為業務邏輯通常涉及多個域模型或一個模型的多個實例。例如,產品價格的計算取決於購買數量以及商品是否在甩賣,因此必須在“用例”層中。另一方面,校驗邏輯通常依賴於模型的一個實例,因此可以將其放入模型中。如果校驗涉及多個模型或模型的多個實例(例如檢查用戶是否重復注冊),則將其放在“用例”層中。
數據傳輸對象(DTO)
這是我沒有遵循清晰架構(Clean Architecture)的另一項。 根據清晰架構(Clean Architecture)¹,“通常跨越邊界的數據是簡單的數據結構。 如果你願意,可以使用基本結構或簡單的數據傳輸對象(DTO)。“在本程序中不使用DTO(數據傳輸對象),而使用域模型進行跨越邊界的數據傳輸。 如果業務邏輯非常復雜,那么擁有一個單獨的DTO可能會有一些好處,那時我不介意創建它們,但現在不需要。
格式轉換
跨越服務邊界時,我們確實需要擁有不同的域模型。 例如本應用程序也作為gRPC微服務發布。 在服務器端,我們使用本程序域模型; 在客戶端,我們使用gRPC域模型,它們的類型是不同的,因此需要進行格式轉換。
// GrpcToUser converts from grpc User type to domain Model user type
func GrpcToUser(user *uspb.User) (*model.User, error) {
if user == nil {
return nil, nil
}
resultUser := model.User{}
resultUser.Id = int(user.Id)
resultUser.Name = user.Name
resultUser.Department = user.Department
created, err := ptypes.Timestamp(user.Created)
if err != nil {
return nil, errors.Wrap(err, "")
}
resultUser.Created = created
return &resultUser, nil
}
// UserToGrpc converts from domain Model User type to grpc user type
func UserToGrpc(user *model.User) (*uspb.User, error) {
if user == nil {
return nil, nil
}
resultUser := uspb.User{}
resultUser.Id = int32(user.Id)
resultUser.Name = user.Name
resultUser.Department = user.Department
created, err := ptypes.TimestampProto(user.Created)
if err != nil {
return nil, errors.Wrap(err, "")
}
resultUser.Created = created
return &resultUser, nil
}
// UserListToGrpc converts from array of domain Model User type to array of grpc user type
func UserListToGrpc(ul []model.User) ([]*uspb.User, error) {
var gul []*uspb.User
for _, user := range ul {
gu, err := UserToGrpc(&user)
if err != nil {
return nil, errors.Wrap(err, "")
}
gul = append(gul, gu)
}
return gul, nil
}
上述數據轉換代碼位於“adapter/userclient”包中。 乍一看,似乎應該讓域模型“User”具有方法“toGrpc()”,它將像這樣執行 - “user.toGrpc(user * uspb.User)”,但這將使業務域模型依賴於gRPC。 因此,最好創建一個單獨的函數並將其放在“adapter/userclient”包中。 該包將依賴於域模型和gRPC模型。 正因為如此,保證了域模型和gRPC模型都是干凈的,它們並不相互依賴。
結論:
本應用程序的設計遵循清晰架構(Clean Architecture)。 業務邏輯代碼有三層:“用例”,“域模型”和“數據服務”。 但是我在兩個方面偏離了清晰架構(Clean Architecture)。 一個是我把大多數業務邏輯代碼放在“用例”層; 另一個是我沒有數據傳輸對象(DTO),而是使用域模型在不同層之間進行共享數據。
源程序:
完整的源程序鏈接 github。
索引:
[3] Go Microservice with Clean Architecture: Transaction Support