一文带你设计、开发简洁架构,掌握构建Go Web应用的炼金术
前言
本文旨在用尽量简短的话描述完在使用Go进行Web开发时,如何设计一种合理的代码架构,来确保代码的可读性、可维护性、可扩展性。
这是本人第一篇文章,不足之处还望包涵。
开发前的预热
了解四层架构设计
在Web开发中,一般都是四层架构设计:
model层(模型层):可以理解为建筑师在构建建筑物之前需要的设计蓝图store层(存储层):可以理解为仓库或库房。在仓库中,物品被存储和管理,可以按需检索和处理。Biz层(业务逻辑层):类比为经理或协调者。经理负责协调和管理团队成员的工作,确保任务按计划完成。controller层(控制器层):接收和处理客户端的请求,协调各个层之间的交互,最终响应客户端的需求。
这种简洁架构设计师当前业界比较流行、比较合理的代码架构,能够确保代码的可读性、可维护性、可扩展性。
开发顺序
四层架构之间的依赖关系是:Controller 层依赖 Biz 层,Biz 层依赖 Store 层,Store 层依赖数据库,而 Controller层、Biz 层、Store 层都依赖 Model 层,如下图所示:
在开发过程中,我一般会选择先开发依赖少的组件,这样可以确保我开发完成后能够随时测试代码功能。
所以我一般会选择:Model层 -> store层 -> Biz层 -> controller层。
搞清楚开发顺序后,接下来就可以一层一层的进行代码开发了,下面我以一个用户注册的功能为例来进行开发。
model层代码开发
在数据库中创建相应表
本文的重点不在于具体的业务逻辑的实现,为了能够简洁的阐述本文的主旨,所以在设计user表的时候,我只设计了三个字段,分别是自增id、用户名、密码。
在数据库中创建表结构的SQL语句为:
CREATE DATABASE `mysystem`;
USE `mysystem`;
CREATE TABLE `user` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
) ENGINE=MyISAM AUTO_INCREMENT=27 DEFAULT CHARSET=utf8;
创建完表后,接下来就可以将表映射到model层了。
映射数据库中相应表到model层
在这里有两种方式:
- 自己手撸代码
- 使用
db2struct工具生成
这里我个人推荐使用工具生成(毕竟在这个信息流的时代,时间就是金钱嘛)。
首先go install github.com/Shelnutt2/db2struct/cmd/db2struct下载好db2struct
接着在model文件夹里面运行如下命令:
db2struct --gorm --no-json -H 127.0.0.1 -d mysystem -t user --package model --struct UserM -u mysql数据库用户名 -p 'mysql数据库密码' --target=user.go
这段命令会在model文件夹下创建一个user.go的文件,其中会有一个命名为UserM的结构体,结构体中的字段就是从数据库中的user表中映射而来的。
这样model层的代码就开发完成了,接下来我们来开发store层。
store层代码开发
设计整体store
在开发store层时,我类比了面向对象的思想。
首先,在面向对象的思想中,Go中的结构体就是一个类,对应结构体的方法就是类中的方法。
那么在设计store层时,首先将整个store层看成一个类,通过实例化这个类作为实例对象后可以与数据库进行CURD操作。
理解了上面我说的时候就可以明白下面的开发思路了:
- 首先创建一个结构体,用来实例化成一个实例对象与数据库进行CRUD操作。那么要进行CRUD操作需要什么呢?自然在结构体中要包含一个
*gorm.DB对象用于CRUD - 接着要创建一个
New函数用来实例化对象操作
代码如下:
package store
import (
"sync"
"gorm.io/gorm"
)
var (
once sync.Once
// 实例化好的实例对象,全局变量
S *datastore
)
// store类
type datastore struct {
db *gorm.DB
}
// New函数进行实例化对象操作
func NewStore(db *gorm.DB) *datastore {
// 确保只被实例化一次
once.Do(func() {
S = &database{db}
})
return S
}
这样,通过调用NewStore函数就能够实例化一个对象赋值给S在全局使用它进行CRUD操作。
接下来是重点:
在Go中,interface可以类比成Java中的抽象类,它定义了一些抽象方法。
在整体的业务逻辑开发中,和user有关的可以整合成一个内部的store,为什么呢?
因为业务开发中,不可能只有一个user业务,以博客系统为例,还会包含一个post业务用来处理文章发布、删除等操作,为了防止我们定义的接口(抽象类)成为下面的这个样子:
type IStore interface {
CreateUser(ctx context.Context, user *model.UserM) error
CreatePost(ctx context.Context, post *model.PostM) error
}
这样看的话可能没什么,但是一旦当业务复杂起来后,一方面要确保方法名不能重名。另一方面在这个接口中会包含所有的CRUD方法,使代码变得难以阅读。
抽离业务逻辑封装成单独的内部store层
所以,将user业务再抽象成一个小的store可以让每个业务在物理上互相隔离,代码变得易维护、阅读。
那么,下面来丰富上面的store包:
package store
import (
"sync"
"gorm.io/gorm"
)
var (
once sync.Once
// 实例化好的实例对象,全局变量
S *datastore
)
// 定义一个IStore接口
type IStore struct {
// 将user业务抽离出来成为一个内部的单独的store
User() UserStore
}
// store类
type datastore struct {
db *gorm.DB
}
// 显示声明让datastore实现IStore接口
var _ IStore = (*datastore)(nil)
// 实现IStore接口中的定义的方法, 返回UserStore接口对应的实例
func (ds *datastore) User() UserStore {
return newUsers(ds.db)
}
// New函数进行实例化对象操作
func NewStore(db *gorm.DB) *datastore {
// 确保只被实例化一次
once.Do(func() {
S = &database{db}
})
return S
}
在上面添加的代码中,我定义一个总的IStore接口,其中IStore接口中有一个User()方法返回的是一个user业务层面的store层实例,同样user业务层的代码与总的Istore代码逻辑一样,两者通过IStore接口中的User()方法项相关联,所以在User()方法中返回的是UserStore的一个实例对象。
user.go代码如下:
package store
import (
"context"
"gorm.io/gorm"
"model"
)
// UserStore接口,实现了创建用户的业务逻辑
type UserStore interface {
Create(ctx context.Context, user *model.UserM) error
}
// UserStore 接口的实现.
type users struct {
db *gorm.DB
}
// 确保 users 实现了 UserStore 接口.
var _ UserStore = (*users)(nil)
func newUsers(db *gorm.DB) *users {
return &users{db}
}
// Create 插入一条 user 记录.
func (u *users) Create(ctx context.Context, user *model.UserM) error {
return u.db.Create(&user).Error
}
这样下来,就可以通过S.User().Create来创建用户了。
总结
其实最终的开发逻辑就是,通过一个整体上的抽象类实现了一个datastore类,将这个类的实例对象保存在全局变量S里面,整体的抽象类又包含了一个创建内部的users实例对象的抽象方法,这样就实现了将users业务在物理层面上封装成一个单独的模块来进行使用,增加了代码的可读性和维护。
工厂模式
这种思想实际上用到了设计模式之一的工厂模式中的工厂方法模式,可以通过实现工厂接口来创建多种工厂,将对象创建从由一个对象负责所有具体类的实例化,变成由一群子类来负责对具体类的实例化,从而将过程解耦。
Biz层代码开发
同样的,理解了上述的store层代码开发后,Biz层代码的开发逻辑和store层一样。
定义一个biz结构体,里面是依赖store层中的datastore。接着同样的将user的业务逻辑抽离出来封装成一个单独的模块。
设计整体Biz
整个Biz层的实现:
// IBiz 定义了 Biz 层需要实现的方法.
type IBiz interface {
Users() user.UserBiz
}
// 确保 biz 实现了 IBiz 接口.
var _ IBiz = (*biz)(nil)
// biz 是 IBiz 的一个具体实现.
type biz struct {
ds store.IStore
}
// 确保 biz 实现了 IBiz 接口.
var _ IBiz = (*biz)(nil)
// NewBiz 创建一个 IBiz 类型的实例.
func NewBiz(ds store.IStore) *biz {
return &biz{ds: ds}
}
// Users 返回一个实现了 UserBiz 接口的实例.
func (b *biz) Users() user.UserBiz {
return user.New(b.ds)
}
抽离业务逻辑封装成单独的内部Biz层
抽离user业务逻辑
// UserBiz 定义了 user 模块在 biz 层所实现的方法.
type UserBiz interface {
Create(ctx context.Context, r *v1.CreateUserRequest) error
}
// UserBiz 接口的实现.
type userBiz struct {
ds store.IStore
}
// 确保 userBiz 实现了 UserBiz 接口.
var _ UserBiz = (*userBiz)(nil)
// New 创建一个实现了 UserBiz 接口的实例.
func New(ds store.IStore) *userBiz {
return &userBiz{ds: ds}
}
// Create 是 UserBiz 接口中 `Create` 方法的实现.
func (b *userBiz) Create(ctx context.Context, r *v1.CreateUserRequest) error {
var userM model.UserM
_ = copier.Copy(&userM, r)
if err := b.ds.Users().Create(ctx, &userM); err != nil {
if match, _ := regexp.MatchString("Duplicate entry '.*' for key 'username'", err.Error()); match {
return errno.ErrUserAlreadyExist
}
return err
}
return nil
}
这样就可以通过以下代码对业务进行处理了:
b := NewBiz(store.S)
b.User().Create()调用创建用户的接口
controller层代码开发
对于这一层的代码开发,由于设计到具体的框架的使用,所以我就不实现代码了,只叙述逻辑。
针对controller层依赖Biz层的实现是:
// UserController 是 user 模块在 Controller 层的实现,用来处理用户模块的请求.
type UserController struct {
b biz.IBiz
}
// New 创建一个 user controller.
func New(ds store.IStore) *UserController {
return &UserController{b: biz.NewBiz(ds)}
}
声明了一个UserController结构体用来调用Biz层的实例对象。
这样就可以通过uc := New(store.S)来实例化一个controller层的对象,接着使用自定义的Create方法来调用Biz的业务逻辑实现,最终打通整个流程。
总结
首先将数据库中的表信息映射到model层,接着通过工厂方法模式构建store 、Biz 层,其中store层生产出来的实例对象赋值给全局变量,之后在controller层中传入store层生产出来的实例给到Biz层,Biz层用这个实例对象来构建自己的实例对象。