提示:本节课最终代码为:feature/s17

开发完基础功能之后,就需要开发业务逻辑相关的代码了。因为相比于基础功能,业务逻辑代码占了整个代码仓库代码量的绝大部分,并且业务代码也更加复杂。这就需要我们设计一种合理的代码架构,来确保代码的可读性、可维护性、可扩展性。当前业界比较流行、比较合理的代码架构是简洁架构。本节课就来带你一步一步设计并开发一个符合简洁架构理念的业务代码架构。

关于简洁架构,你可以参考 Clean Architecture with GO 进行学习。

4 层架构开发

根据我们所设计的 4 层架构,我们可知以下依赖关系:Controller 层依赖 Biz 层,Biz 层依赖 Store 层,Store 层依赖数据库,而 Controller层、Biz 层、Store 层都依赖 Model 层,如下图所示:

12. 业务架构:如何设计、开发简洁架构? - 图1

为了,能够随时测试我们所开发的代码功能,最好的方式是先开发依赖少的组件。否则,你需要先 Mock 或者开发所依赖的功能。所以,我们的开发顺序为:Model 层 -> Store 层 -> Biz 层 -> Controller 层。

每一层,又有很多功能,这里建议,不要把每一层的功能都开发完成之后,再开发其他层。这样会造成整个应用完整运行起来周期很长。最好的方法是先只开发一个功能链路,例如:先开发用户创建业务功能。这样,可以很快地运行起整个应用框程序,提前发现并测试应用。其他的业务功能,也可以拷贝已开发的代码,二次修改快速完成开发。

Model 层代码开发

miniblog 的 Model 层其实就是数据库表字段的 Go 结构体映射。你可以根据 miniblog 数据库中的表来创建 Model:

12. 业务架构:如何设计、开发简洁架构? - 图2

可以看到 miniblog 数据库中有 3 张表,这 3 张表,是你在开发项目之前设计数据库表结构时创建的:

  • casbin_rule:用来存放 casbin 的授权策略,由 casbin 库自动创建,这里我们无需关注,后面会详细介绍;

  • user:用来存储用户信息;

  • post:用来存储博客信息。

接下来,我们就要根据表名、表字段、表字段类型,来创建 Model 层的 Go 结构体,用来映射数据库中对应的表。这里建议一个表一个文件,文件名和表明保持一致,这样方便后期的查找和维护。

比如,user 表的 Go 结构体映射保存在 internal/pkg/model/user.go 文件中。结构体名命名为 UserM。结构体命名也是有规范的,格式为:表名大驼峰名字M,语义化的命令方式,可以带来以下好处:

  • 通过后缀 M,我们知道这是一个 Model 层的结构体,专门用来映射数据库表的;

  • 通过 User 我们知道,这个结构体映射的是数据库中的 user 表。

语义化的命名方式,可以使我们明确知道这个结构体的用途、代表的结构体名,从而可以提高开发效率、减少理解成本。

UserM 表你可以自己手撸代码,也可以借助工具自动生成,这里我建议借助工具自动生成。具体分为以下几步:

  1. 创建数据库和数据库表;

  2. 根据数据库表生成 Model 文件;

  3. 修改生成的 Go 代码。

每一步的操作如下。

  1. 创建数据库和数据库表。

创建数据库:

  1. CREATE DATABASE `miniblog`;
  2. USE `miniblog`;

创建 user 表,创建 SQL 语句为:

  1. CREATE TABLE `user` (
  2. `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  3. `username` varchar(255) NOT NULL,
  4. `password` varchar(255) NOT NULL,
  5. `nickname` varchar(30) NOT NULL,
  6. `email` varchar(256) NOT NULL,
  7. `phone` varchar(16) NOT NULL,
  8. `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  9. `updatedAt` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
  10. PRIMARY KEY (`id`),
  11. UNIQUE KEY `username` (`username`)
  12. ) ENGINE=MyISAM AUTO_INCREMENT=27 DEFAULT CHARSET=utf8;

创建 post 表,创建 SQL 语句为:

  1. CREATE TABLE `post` (
  2. `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  3. `username` varchar(255) NOT NULL,
  4. `postID` varchar(256) NOT NULL,
  5. `title` varchar(256) NOT NULL,
  6. `content` longtext NOT NULL,
  7. `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  8. `updatedAt` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
  9. PRIMARY KEY (`id`),
  10. UNIQUE KEY `postID` (`postID`),
  11. KEY `idx_username` (`username`)
  12. ) ENGINE=InnoDB AUTO_INCREMENT=141 DEFAULT CHARSET=utf8;
  1. 根据数据库表生成 Model 文件。

执行以下命令生成:

  1. $ mkdir -p internal/pkg/model
  2. $ cd internal/pkg/model
  3. $ db2struct --gorm --no-json -H 127.0.0.1 -d miniblog -t user --package model --struct UserM -u miniblog -p 'miniblog1234' --target=user.go
  4. $ db2struct --gorm --no-json -H 127.0.0.1 -d miniblog -t post --package model --struct PostM -u miniblog -p 'miniblog1234' --target=post.go

db2struct 命令行参数说明如下:

  1. $ db2struct -h
  2. Usage of db2struct:
  3. db2struct [-H] [-p] [-v] --package pkgName --struct structName --database databaseName --table tableName
  4. Options:
  5. -H, --host= Host to check mariadb status of
  6. --mysql_port=3306 Specify a port to connect to
  7. -t, --table= Table to build struct from
  8. -d, --database=nil Database to for connection
  9. -u, --user=user user to connect to database
  10. -v, --verbose Enable verbose output
  11. --package= name to set for package
  12. --struct= name to set for struct
  13. --json Add json annotations (default)
  14. --no-json Disable json annotations
  15. --gorm Add gorm annotations (tags)
  16. --guregu Add guregu null types
  17. --target= Save file path
  18. -p, --password= Mysql password
  19. -h, --help Show usage message
  20. --version Show version

生成后的文件见:user.gopost.go。在实际开发中, 其实有很多工具,可以节省我们的开发效率,建议,你在开发的过程中,如果觉得一个功能或者操作可能会有相应的工具来自动化实现,你就可以去 Google 或者在 GitHub 上搜索这类工具。

最后,为了以后的部署,我们使用 mysqldump 将创建数据库和表的 SQL 语句保存在 configs 目录下供以后部署使用:

  1. $ mysqldump -h127.0.0.1 -uminiblog --databases miniblog -p'miniblog1234' --add-drop-database --add-drop-table --add-drop-trigger --add-locks --no-data > configs/miniblog.sql
  1. 修改生成的 Go 代码。

这里,我修改了源文件中的代码注释,方便你看源码时理解。

至此,Model 层开发完成,完整代码见:feature/s14

Store 层代码开发

有了 Model 层的代码,接下来我们就可以开发 Store 层的代码。那么如何开发 Store 层呢?我的思路如下(创建 Controller 层、Biz 层思路类似):

  • 首先,要创建一个结构体,用来创建 Store 层的实例。自然的,你会想到要在改结构体中包含一个 *gorm.DB 对象,用于与数据库的 CURD;

  • 接着,创建一个 New 函数,用来创建 Store 层实例;

  • 接着,为了方便直接调用 store 包,引用 Store 层的实例,我们还要设置一个包级别的 Store 实例;

  • 最后,为了避免实例被重复创建,通常我们需要使用 sync.Once 来确保实例只被初始化一次。

其实,上述思路,使用了设计模式中的工厂模式(工厂方法模式)。如果你是初学者,可能不了解工厂模式,不一定能靠自己独立思考想到使用工厂模式,没关系的,这是正常阶段。这里我直接把最优雅的创建方式告诉你,这也是本课程的价值之一。关于工厂模式,你可以参考这篇文章来学习:Go 工厂模式

最终完成后的代码分别保存在:internal/miniblog/store/store.gointernal/miniblog/store/user.go 文件中。

internal/miniblog/store/store.go 文件内容如下:

  1. package store
  2. import (
  3. "sync"
  4. "gorm.io/gorm"
  5. )
  6. var (
  7. once sync.Once
  8. // 全局变量,方便其它包直接调用已初始化好的 S 实例.
  9. S *datastore
  10. )
  11. // IStore 定义了 Store 层需要实现的方法.
  12. type IStore interface {
  13. Users() UserStore
  14. }
  15. // datastore 是 IStore 的一个具体实现.
  16. type datastore struct {
  17. db *gorm.DB
  18. }
  19. // 确保 datastore 实现了 IStore 接口.
  20. var _ IStore = (*datastore)(nil)
  21. // NewStore 创建一个 IStore 类型的实例.
  22. func NewStore(db *gorm.DB) *datastore {
  23. // 确保 S 只被初始化一次
  24. once.Do(func() {
  25. S = &datastore{db}
  26. })
  27. return S
  28. }
  29. // Users 返回一个实现了 UserStore 接口的实例.
  30. func (ds *datastore) Users() UserStore {
  31. return newUsers(ds.db)
  32. }

internal/miniblog/store/user.go 文件内容如下:

  1. package store
  2. import (
  3. "context"
  4. "gorm.io/gorm"
  5. "github.com/marmotedu/miniblog/internal/pkg/model"
  6. )
  7. // UserStore 定义了 user 模块在 store 层所实现的方法.
  8. type UserStore interface {
  9. Create(ctx context.Context, user *model.UserM) error
  10. }
  11. // UserStore 接口的实现.
  12. type users struct {
  13. db *gorm.DB
  14. }
  15. // 确保 users 实现了 UserStore 接口.
  16. var _ UserStore = (*users)(nil)
  17. func newUsers(db *gorm.DB) *users {
  18. return &users{db}
  19. }
  20. // Create 插入一条 user 记录.
  21. func (u *users) Create(ctx context.Context, user *model.UserM) error {
  22. return u.db.Create(&user).Error
  23. }

这里介绍一下上述 2 段代码中的一些设计思路和思考。

在开发阶段,我习惯使用以下赋值方式,来确保结构体实现了期望的接口:

  1. var _ IStore = (*datastore)(nil)
  2. var _ UserStore = (*users)(nil)

这种赋值语句会带来以下好处:

  • 编译期间发现问题: 能够确保 datastoreusers 实现了期望的接口;

  • 语义化: 能够使代码阅读者明确知道 datastoreusersIStoreUserStore 接口的一个具体实现。

我们使用了工厂方法设计模式,实现了以下风格的代码:

  1. type IStore interface {
  2. Users() UserStore
  3. }
  4. type UserStore interface {
  5. Create(ctx context.Context, user *model.UserM) error
  6. }

最初的实现思路来源是什么?为什么要这样实现?其实这种实现思路并不是我凭空想出来的,是我通过阅读其他源码学到的:clientset.go#L80

在你的 Go 开发生涯中,你也需要多阅读优秀的开源项目,学习他们的实现方式,并试着理解背后的设计原因。那么,我为什么要参考 client-go 的这种设计方式呢?一方面是能够发挥工厂方法模式的优点,另一方面,IStore 中会包含 user 表、post 表的 CURD 操作。

很多开源项目会这么来实现:

  1. type IStore interface {
  2. CreateUser(ctx context.Context, user *model.UserM) error
  3. CreatePost(ctx context.Context, post *model.PostM) error
  4. }

但我觉得这样不优雅,原因如下:

  • 在实现 post 表的 CURD 方法时,还要考虑避免跟 user 表的 CURD 方法重名。

  • 如果 Store 层表很多,IStore 接口中的方法就会很多,难以阅读。

所以这里,我通过工厂方法设计模式,将 user 表、post 表的实现完全独立开,各自实现相互不影响,代码也分别存放在 user.gopost.go 文件中,从物理上隔离开 2 个表的代码实现。这样的代码更易阅读和维护。

上述代码中,我们使用以下代码段创建了一个 IStore 接口类型的实例:

  1. func NewStore(db *gorm.DB) *datastore {
  2. // 确保 S 只被初始化一次
  3. once.Do(func() {
  4. S = &datastore{db}
  5. })
  6. return S
  7. }

这里使用了 sync.Once 包,来确保实例只被初始化一次,创建完成后,赋值给包级别的变量 S, 方便我们使用 store.S.Users().Create() 的方式来使用包提供的功能。Go 最佳实践中,建议少用包级别的变量,因为包级别的变量不容易感知,会带来一定的维护复杂度。但这个不是绝对的,可以根据需要来选择是否需要设置包级别的变量,来简化开发。

Store 层依赖 *gorm.DB 实例,所以接下来,我们还需要创建 *gorm.DB 实例。因为创建 *gorm.DB 是一个项目无关的,可供第三方项目引用的动作,所以,我们选择将创建方式以包的形式存放在项目根目录下的 pkg/ 目录下:pkg。代码如下:

  1. package db
  2. import (
  3. "fmt"
  4. "time"
  5. "gorm.io/driver/mysql"
  6. "gorm.io/gorm"
  7. "gorm.io/gorm/logger"
  8. )
  9. // MySQLOptions 定义 MySQL 数据库的选项.
  10. type MySQLOptions struct {
  11. Host string
  12. Username string
  13. Password string
  14. Database string
  15. MaxIdleConnections int
  16. MaxOpenConnections int
  17. MaxConnectionLifeTime time.Duration
  18. LogLevel int
  19. }
  20. // DSN 从 MySQLOptions 返回 DSN.
  21. func (o *MySQLOptions) DSN() string {
  22. return fmt.Sprintf(`%s:%s@tcp(%s)/%s?charset=utf8&parseTime=%t&loc=%s`,
  23. o.Username,
  24. o.Password,
  25. o.Host,
  26. o.Database,
  27. true,
  28. "Local")
  29. }
  30. // NewMySQL 使用给定的选项创建一个新的 gorm 数据库实例.
  31. func NewMySQL(opts *MySQLOptions) (*gorm.DB, error) {
  32. logLevel := logger.Silent
  33. if opts.LogLevel != 0 {
  34. logLevel = logger.LogLevel(opts.LogLevel)
  35. }
  36. db, err := gorm.Open(mysql.Open(opts.DSN()), &gorm.Config{
  37. Logger: logger.Default.LogMode(logLevel),
  38. })
  39. if err != nil {
  40. return nil, err
  41. }
  42. sqlDB, err := db.DB()
  43. if err != nil {
  44. return nil, err
  45. }
  46. // SetMaxOpenConns 设置到数据库的最大打开连接数
  47. sqlDB.SetMaxOpenConns(opts.MaxOpenConnections)
  48. // SetConnMaxLifetime 设置连接可重用的最长时间
  49. sqlDB.SetConnMaxLifetime(opts.MaxConnectionLifeTime)
  50. // SetMaxIdleConns 设置空闲连接池的最大连接数
  51. sqlDB.SetMaxIdleConns(opts.MaxIdleConnections)
  52. return db, nil
  53. }

因为创建 *gorm.DB 对象,需要参数较多,所以我将需要的配置项,放在 MySQLOptions 结构体中统一维护。然后通过 NewMySQL 函数创建 *gorm.DB

上述 db 包的实现结构如下:

  1. type XXXOptions struct {
  2. // Fields
  3. }
  4. type xxx struct {
  5. // Fields
  6. }
  7. func NewXXX(opts *XXXOptions) (*xxx, error) {
  8. // Create logic that is not perceived by the outside world
  9. return &xxx{}, nil
  10. }
  11. func (x *xxx) FuncA() string {
  12. return ""
  13. }

上述代码,其实使用了工厂模式中的简单工厂模式,对外隐藏了实现的细节。你在实际开发中,如果需要开发一个独立的包,可以优先考虑上述实现方式。

最后,我们需要在 main 流程中初始化 Store 层:在 miniblog.go#L89 文件中添加一行初始化代码:

  1. if err := initStore(); err != nil {
  2. return err
  3. }

并将 initStore 具体实现放在 helper.go#L85 文件中。通过将具体实现放在 helper.go 文件中,可以使 main 流程保持简洁、清晰,易于阅读和维护。

至此,Store 层开发完成,完整代码见:feature/s15

Biz 层代码开发

有了 Store 层的代码,接下来我们就可以开发 Biz 层的代码。Biz 层的开发思路和 Store 层的开发思路完全保持一致。

首先,我们使用工厂模式开发了用以创建 Biz 层实例的代码(见 internal/miniblog/biz/biz.go):

  1. // IBiz 定义了 Biz 层需要实现的方法.
  2. type IBiz interface {
  3. Users() user.UserBiz
  4. }
  5. // 确保 biz 实现了 IBiz 接口.
  6. var _ IBiz = (*biz)(nil)
  7. // biz 是 IBiz 的一个具体实现.
  8. type biz struct {
  9. ds store.IStore
  10. }
  11. // 确保 biz 实现了 IBiz 接口.
  12. var _ IBiz = (*biz)(nil)
  13. // NewBiz 创建一个 IBiz 类型的实例.
  14. func NewBiz(ds store.IStore) *biz {
  15. return &biz{ds: ds}
  16. }
  17. // Users 返回一个实现了 UserBiz 接口的实例.
  18. func (b *biz) Users() user.UserBiz {
  19. return user.New(b.ds)
  20. }

这里可以看到,我将 UserBiz 的实现放在了 internal/miniblog/biz/user 目录中,原因是考虑到以后业务逻辑层代码量会比较大,按 REST 资源保存在不同的目录中,后期阅读和维护都会比较简单。在创建 *biz 实例的时候,我们传入了其所依赖的 Store 层实例 ds,并用 ds 创建 IBiz 的子类。

来看下 UserBiz,该接口是实现了 user REST 资源的具体逻辑(这里是创建用户),代码如下(internal/miniblog/biz/user/user.go):

  1. // UserBiz 定义了 user 模块在 biz 层所实现的方法.
  2. type UserBiz interface {
  3. Create(ctx context.Context, r *v1.CreateUserRequest) error
  4. }
  5. // UserBiz 接口的实现.
  6. type userBiz struct {
  7. ds store.IStore
  8. }
  9. // 确保 userBiz 实现了 UserBiz 接口.
  10. var _ UserBiz = (*userBiz)(nil)
  11. // New 创建一个实现了 UserBiz 接口的实例.
  12. func New(ds store.IStore) *userBiz {
  13. return &userBiz{ds: ds}
  14. }
  15. // Create 是 UserBiz 接口中 `Create` 方法的实现.
  16. func (b *userBiz) Create(ctx context.Context, r *v1.CreateUserRequest) error {
  17. var userM model.UserM
  18. _ = copier.Copy(&userM, r)
  19. if err := b.ds.Users().Create(ctx, &userM); err != nil {
  20. if match, _ := regexp.MatchString("Duplicate entry '.*' for key 'username'", err.Error()); match {
  21. return errno.ErrUserAlreadyExist
  22. }
  23. return err
  24. }
  25. return nil
  26. }

UserBiz 的创建思路和 UserStore 保持一致。在 Create 方法中,实现了具体的创建逻辑:

  • 接受来自 Controller 层的入参:context.Context*v1.CreateUserRequest

  • 根据 Store 层 Users().Create() 的入参要求,构建 UserM 结构体;

  • 调用 Users().Create() 创建用户,并检查返回结果。这里检查了报错是否是用户已存在。如果是,就返回指定的业务错误(ErrUserAlreadyExist 是我们新增的业务错误)。

为了提高开发效率,简化代码量,我在从 v1.CreateUserRequest 构造 model.UserM 的时候,使用了 github.com/jinzhu/copier 包的 Copy 函数。copier 包如何使用,你可以参考 copier 仓库下的 :README.md

v1.CreateUserRequest 定义如下(见 pkg/api/miniblog/v1/user.go):

  1. // CreateUserRequest 指定了 `POST /v1/users` 接口的请求参数.
  2. type CreateUserRequest struct {
  3. Username string `json:"username" valid:"alphanum,required,stringlength(1|255)"`
  4. Password string `json:"password" valid:"required,stringlength(6|18)"`
  5. Nickname string `json:"nickname" valid:"required,stringlength(1|255)"`
  6. Email string `json:"email" valid:"required,email"`
  7. Phone string `json:"phone" valid:"required,stringlength(11|11)"`
  8. }

这里,我解释下为什么会将 CreateUserRequest 结构体的定义文件 user.go 存放在 pkg/api/miniblog/v1 目录下:

  • CreateUserRequest 对用户暴露,作为 POST /v1/users 接口的请求 Body,所以我将 user.go 放在了 pkg/ 目录下;

  • 另外,为了能让后来的代码维护者或者包的使用者感知到 CreateUserRequest 是专门用来做请求参数的结构体,我将 user.go 放在了 pkg/api 目录下;

  • 另外,考虑到未来 miniblog 可能会加入多个服务,每个服务都有自己的对外 API 定义,这里我新建了一个目录 miniblog,将 user.go 存放在了 pkg/api/miniblog 目录下;

  • 最后,考虑到未来 API 接口的版本升级和维护,这里我们也创建了一层 v1 目录用来保存不同版本包的请求参数结构体。所以最终,user.go 存放在了 pkg/api/miniblog/v1/ 目录下。

可以看到,在开发过程中,我们需要时刻保持一个功能的可扩展性、可维护性。

提示:CreateUserRequestvalid tag 用来做参数校验,后面会介绍到。

至此,Biz 层开发完成,完整代码见:feature/s16

Controller 层代码开发

有了 Biz 层的代码,接下来我们就可以开发 Controller 层的代码。Controller 层的开发思路和 Store 层、Biz 层的开发思路保持一致。

Controller 层代码见:internal/miniblog/controller/v1/user/ 目录。

user.go 文件用来创建 UserController

  1. // UserController 是 user 模块在 Controller 层的实现,用来处理用户模块的请求.
  2. type UserController struct {
  3. b biz.IBiz
  4. }
  5. // New 创建一个 user controller.
  6. func New(ds store.IStore) *UserController {
  7. return &UserController{b: biz.NewBiz(ds)}
  8. }

Controller 依赖 Biz,Biz 依赖 Store,所以我们传入了 IStore 类型的参数 ds 用来创建 UserController

UserController 具有 Create 方法,用来实现 POST /v1/users 接口:

  1. func (ctrl *UserController) Create(c *gin.Context) {
  2. log.C(c).Infow("Create user function called")
  3. var r v1.CreateUserRequest
  4. if err := c.ShouldBindJSON(&r); err != nil {
  5. core.WriteResponse(c, errno.ErrBind, nil)
  6. return
  7. }
  8. if _, err := govalidator.ValidateStruct(r); err != nil {
  9. core.WriteResponse(c, errno.ErrInvalidParameter.SetMessage(err.Error()), nil)
  10. return
  11. }
  12. if err := ctrl.b.Users().Create(c, &r); err != nil {
  13. core.WriteResponse(c, err, nil)
  14. return
  15. }
  16. core.WriteResponse(c, nil, nil)
  17. }

前面介绍过,Controller 层主要完成:接收 HTTP 请求,并进行参数解析、参数校验、逻辑分发处理、请求返回操作。

上述代码,使用 c.ShouldBindJSON 方法将请求 Body 中的参数直接解析到 v1.CreateUserRequest 结构体中。使用 govalidator.ValidateStruct 来进行参数校验。govalidator 包能够根据结构体中的 valid tag 进行校验,并支持多种校验规则,具体可参考:asaskevich/govalidator。调用 ctrl.b.Users().Create 来完成用户的创建。调用 core.WriteResponse 做参数返回。

通过在 Controller 层实现有限的功能(参数解析、校验、逻辑分发、请求聚合和返回),并将负责的业务逻辑放在 Biz 层实现,可以使 Controller 层代码逻辑结构清晰,利于后期的代码维护。

Create 方法中,我们新增了 2 个通用的错误类型:ErrBindErrInvalidParameter ,分别用来在参数解析错误、参数校验错误时返回。

有了 Controller 层的方法之后,我们就可以将该方法绑定到指定的路由上(API 路径上),代码见 router.go#L19

  1. // installRouters 安装 miniblog 接口路由.
  2. func installRouters(g *gin.Engine) error {
  3. // 注册 404 Handler.
  4. g.NoRoute(func(c *gin.Context) {
  5. core.WriteResponse(c, errno.ErrPageNotFound, nil)
  6. })
  7. // 注册 /healthz handler.
  8. g.GET("/healthz", func(c *gin.Context) {
  9. log.C(c).Infow("Healthz function called")
  10. core.WriteResponse(c, nil, map[string]string{"status": "ok"})
  11. })
  12. uc := user.New(store.S)
  13. // 创建 v1 路由分组
  14. v1 := g.Group("/v1")
  15. {
  16. // 创建 users 路由分组
  17. userv1 := v1.Group("/users")
  18. {
  19. userv1.POST("", uc.Create)
  20. }
  21. }
  22. return nil
  23. }

我们将注册 /healthz 路由的代码从 miniblog.go 文件中迁移到了 router.go 文件中。是因为:未来可能不断会有新的路由注册进来,分开 2 个文件,可以使得路由注册的代码变更隔离在 router.go 文件中,可以提高代码的可维护性。

提示:在代码开发的过程中,代码优化是个持续的过程,贯穿整个软件生命周期。

至此,Controller 层开发完成,完整代码见:feature/s17

最后,我们在创建一个用户的时候,需要将明文密码加密后,存放到数据库中。这里我们使用 GORM 提供的 BeforeCreate Hook(在 internal/pkg/model/user.go 文件中追加以下代码段):

  1. // BeforeCreate 在创建数据库记录之前加密明文密码.
  2. func (u *UserM) BeforeCreate(tx *gorm.DB) (err error) {
  3. // Encrypt the user password.
  4. u.Password, err = auth.Encrypt(u.Password)
  5. if err != nil {
  6. return err
  7. }
  8. return nil
  9. }

上述代码段会在记录保存在数据库之前,调用 auth.Encrypt 加密密码并重新赋值给 u.Password

auth 包我们要从 0 开发吗?当然不用,我的思路是这样的,密码加密是一个非常基础的功能,网上一定有很多实现,所以,你首先可以想到从 GitHub 上找现有的代码,这里我直接复用了:component-base 项目的 auth 包。

这里说下,为什么我能找到开源的 auth 包:我会从 GitHub 上克隆很多优秀的实战项目到本地,例如:iamgin-admingin-webgo-admingin-vue-adminkubernetes 等,然后开发的时候,通过 grep 目录查找可能有类似实现的项目,例如:grep ``golang.org/x/crypto/bcrypt`` *,然后根据输出,判断哪些包可能有实现。

因为我平时喜欢调研以下优质的项目,所以本地的 $GOPATH/src/``github.com 目录下保存了大量的优质 Go 项目,基本上都能找到自己想要的现有实现。

在实际开发中,当我遇到一个功能不知道如何实现、一个错误表述不知道怎么用英文表述、一个包不知道怎么命名、一个变量不知道如何命名的时候,我会优先参考 kubernetes 的代码实现,帮助我做决策。

提示:当然,你也可以在 GitHub 上搜索,因为 GitHub 范围有点大,而且很多项目并不是高质量的项目,所以会给搜索增加很多困难。

编译、启动、测试

上面,我带着你一步一步完成了 Model 层、Store 层、Biz 层、Controller 层的代码,并注册了 HTTP 路由。至此,我们其实已经开发完成了主体业务逻辑,并能够对外提供服务:创建用户。

接下来,我们就先来创建一个 root 用户,密码为 miniblog1234。编译并启动服务:

  1. $ make
  2. $ $ _output/miniblog -c configs/miniblog.yaml

在另一个 Linux 终端中执行以下命令:

  1. $ curl -XPOST -H"Content-Type: application/json" -d'{"username":"root","password":"miniblog1234","nickname":"root","email":"nosbelm@qq.com","phone":"1818888xxxx"}' http://127.0.0.1:8080/v1/users
  2. null

查询数据库,看用户是否被成功创建:

  1. MariaDB [miniblog]> select * from user;
  2. +----+----------+--------------+----------+----------------+-------------+---------------------+---------------------+
  3. | id | username | password | nickname | email | phone | createdAt | updatedAt |
  4. +----+----------+--------------+----------+----------------+-------------+---------------------+---------------------+
  5. | 31 | root | miniblog1234 | root | nosbelm@qq.com | 1818888xxxx | 2022-12-02 16:02:39 | 2022-12-02 16:02:39 |
  6. +----+----------+--------------+----------+----------------+-------------+---------------------+---------------------+

可以看到,数据库 user 表中新增了一个 root 用户。

小结

本节课,带领大家从 0 到 1 构建起了整个业务逻辑,并分享了我的思路、流程和经验。之后,你可以根据创建用户功能的添加流程,添加其他业务功能。

最后,也给大家留 2 个小作业:

  • 思考下为什么要使用 datastore 而不是 Datastore
  • 考虑下,怎么使用依赖注入优化本节课的代码?