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

我使用Go和gRPC建立了一個微服務,並將程序設計和編程的最佳實踐應用於該項目。 我寫了一系列關於在項目工做中作出的設計決策和取捨的文章,此篇是關於程序設計。html

程序的設計遵循清晰架構(Clean Architecture)¹。 業務邏輯代碼分三層:用例(usecase),域模型(model)和數據服務(dataservice)。git

有三個頂級包「usecase」,「model」和「dataservice」,每層一個。 在每一個頂級包(模型除外)中只有一個以該包命名的文件。 該文件爲每一個包定義了外部世界的接口。 從頂層向下的依賴結構層次是:「usecase」,「dataservice」和「model」。 上層包依賴於較低層的包,依賴關係永遠不會反向。github

用例(usecase):

「usecase」是應用程序的入口點,本項目大部分業務邏輯都在用例層。 我從這篇文章²中得到了部分業務邏輯思路。 有三個用例「registration」,「listUser」和「listCourse」。 每一個用例都實現了一個業務功能。 用例可能與真實世界的用例不一樣,它們的建立是爲了說明設計理念。 如下是註冊用例的接口:golang

// 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」函數將經過此接口調用「用例」,該接口僅依賴於模型層。sql

如下是「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」的數據持久層的接口。json

// 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

[2]Clean Architecture in Go

[3] Go Microservice with Clean Architecture: Transaction Support

[4]ozzo-validation

[5] Input validation in GoLang

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

相關文章
相關標籤/搜索