清晰架構(Clean Architecture)的 Go 微服務: 程序設計

我使用 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: https://github.com/jfeng45/servicetmpl

索引:

[1]The Clean Code Blog

[2]Clean Architecture in Go

[3] Go Microservice with Clean Architecture: Transaction Support

[4]ozzo-validation

[5] Input validation in GoLang

不堆砌術語,不羅列架構,不迷信權威,不盲從流行,堅持獨立思考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://blog.csdn.net/weixin_38748858/article/details/103708927