我使用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。

索引:

[1][The Clean Code Blog](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)

[2][Clean Architecture in Go](https://medium.com/@hatajoe/clean-architecture-in-go-4030f11ec1b1)

[3] Go Microservice with Clean Architecture: Transaction Support

[4][ozzo-validation](https://github.com/go-ozzo/ozzo-validation)

[5] Input validation in GoL