25

清晰架构(Clean Architecture)的Go微服务: 程序设计

 4 years ago
source link: http://www.cnblogs.com/code-craftsman/p/12100291.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK