一文带你设计、开发简洁架构,掌握构建Go Web应用的炼金术

384 阅读9分钟

一文带你设计、开发简洁架构,掌握构建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层,接着通过工厂方法模式构建storeBiz 层,其中store层生产出来的实例对象赋值给全局变量,之后在controller层中传入store层生产出来的实例给到Biz层,Biz层用这个实例对象来构建自己的实例对象。

架构关系