Go学习圣经:Go语言实现高并发CRUD业务开发

说在前面:

现在拿到offer超级难,甚至连面试电话,一个都搞不到。

尼恩的技术社群中(50+),很多小伙伴凭借 “左手云原生+右手大数据”的绝活,拿到了offer,并且是非常优质的offer,据说年终奖都足足18个月

第二个案例就是:前段时间,一个2年小伙伴希望涨薪到18K, 尼恩把GO 语言的项目架构,给他写入了简历,导致他的简历金光闪闪,脱胎换骨,完全可以去拿头条、腾讯等30K的offer, 年薪可以直接多 20W

第二个案例就是:一个6年小伙伴凭借Java+go双语言云原生架构,年薪60W

从Java高薪岗位和就业岗位来看,云原生、K8S、GO 现在对于 高级工程师/架构师 来说,越来越重要。

所以,尼恩从架构师视角出发,基于尼恩 3高架构知识宇宙,写一本《GO学习圣经》,请到文末【技术自由圈】取。

《GO学习圣经》已经完成的内容有:

Go学习圣经:0基础精通GO开发与高并发架构

Go学习圣经:队列削峰+批量写入 超高并发原理和实操

Go学习圣经:从0开始,精通Go语言Rest微服务架构和开发

《GO学习圣经》PDF的最终目标

咱们的目标,不仅仅在于 GO 应用编程自由,更在于 GO 架构自由。

另外,前面尼恩的云原生是没有涉及GO的,但是,没有GO的云原生是不完整的。

所以, GO语言、GO架构学习完了之后,咱们再去打个回马枪,完成云原生的第二部分: 《Istio + K8S CRD的架构与开发实操》 , 帮助大家彻底穿透云原生。

文章目录

业务CRUD的Restful API接口层的设计与开发

本文,围绕一个简单的商品微服务CRUD案例进行介绍。

业务CRUD的Restful API接口的设计

参照 CRUD的Restful API接口的设计规范,完成类似下面的CRUD接口设计。

功能 HTTP 方法 路径
新增文章 POST /articles
删除指定文章 DELETE /articles/:id
更新指定文章 PUT /articles/:id
获取指定文章 GET /articles/:id
获取文章列表 GET /articles

增删改查的 RESTful API 设计和编写,在 RESTful API 中 HTTP 方法对应的行为动作分别如下:

  • GET:读取/检索动作。
  • POST:新增/新建动作。
  • PUT:更新动作,用于更新一个完整的资源,要求为幂等。
  • PATCH:更新动作,用于更新某一个资源的一个组成部分,也就是只需要更新该资源的某一项,就应该使用 PATCH 而不是 PUT,可以不幂等。
  • DELETE:删除动作。

Restful API路由管理,类似于SpringMVC 控制器

Restful API路由管理,类似于SpringMVC 控制器。

Restful API路由管理,放在 internal/routers 目录下,并新建 router.go 文件,代码参考:

func NewRouter() *gin.Engine {
    
    
    r := gin.New()
    r.Use(gin.Logger())
    r.Use(gin.Recovery())
   article := v1.NewArticle()
   apiv1 := r.Group("/api/v1")
   {
    
    
    apiv1.POST("/articles", article.Create)
    apiv1.DELETE("/articles/:id", article.Delete)
    apiv1.PUT("/articles/:id", article.Update)
    apiv1.PATCH("/articles/:id/state", article.Update)
    apiv1.GET("/articles/:id", article.Get)
    apiv1.GET("/articles", article.List)
    }
    return r
}

Handler 处理器的设计和实现

Handler 处理器 ,对应的就是 SpringMVC的 Controller。

Handler 处理器 的位置,这里是放在 internal/routers/api/v1 文件夹。

这里的Handler处理器 ,文件名称叫做 article.go。

参考的代码如下:

type Article struct{
    
    }
func NewArticle() Article {
    
    
    return Article{
    
    }
}
func (a Article) Get(c *gin.Context) {
    
    }
func (a Article) List(c *gin.Context) {
    
    }
func (a Article) Create(c *gin.Context) {
    
    }
func (a Article) Update(c *gin.Context) {
    
    }
func (a Article) Delete(c *gin.Context) {
    
    }

启动Gin WEB服务器

在完成了模型、路由的代码编写后,修改 main.go 文件,把它改造为这个项目的启动文件,

修改代码如下:

func main() {
    
    
    router := routers.NewRouter()
    s := &http.Server{
    
    
        Addr:           ":8080",
        Handler:        router,
        ReadTimeout:    10 * time.Second,
        WriteTimeout:   10 * time.Second,
        MaxHeaderBytes: 1 << 20,
    }
    s.ListenAndServe()
}

我们通过自定义 http.Server,设置了监听的 TCP Endpoint、处理的程序、允许读取/写入的最大时间、请求头的最大字节数等基础参数,最后调用 ListenAndServe 方法开始监听。

业务CRUD的Restful API服务层的设计与开发

服务层的职责和功能,类似于SpringMVC 服务层。

服务层的基础类

package service

import (
	"context"
	"crazymakercircle.com/gin-rest/common/global"
	"crazymakercircle.com/gin-rest/internal/dao"
	otgorm "github.com/eddycjy/opentracing-gorm"
)

type Service struct {
    
    
	ctx context.Context
	dao *dao.Dao
}

func New(ctx context.Context) Service {
    
    
	svc := Service{
    
    ctx: ctx}
	svc.dao = dao.New(global.DBEngine)
	svc.dao = dao.New(otgorm.WithContext(svc.ctx, global.DBEngine))
	return svc
}

基础类封层了两个对象:

  • context.Context 类型的上下文对象
  • dao.Dao 类型的 dao层 基础对象

context.Context 类型的上下文对象

在 Go http包的Server中,每一个请求在都有一个对应的 goroutine 去处理。

每一个请求handler处理函数,很多的时候,也是需要并发处理的,往往会启动额外的 goroutine 用来访问后端服务,比如访问数据库、调用RPC服务。

虽然db协程、rpc协程和req协程都是并发执行的。

但是,当req协程请求被取消时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统迅速释放这些 goroutine 占用的资源。

Context的原理

Go1.7加入了一个新的标准库context,它定义了Context类型。

Context上下文 专门用来简化单个WEB请求的多个 goroutine 之间与请求有关的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。

对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。

它们之间的函数调用链必须传递上下文,或者可以使用WithCancel、WithDeadline、WithTimeout或WithValue创建的派生上下文。

注意: 当一个上下文被取消时,它派生的所有上下文也被取消。

Context接口

context.Context是一个接口,该接口定义了四个需要实现的方法。具体签名如下:

type Context interface {
    
    
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{
    
    }
    Err() error
    Value(key interface{
    
    }) interface{
    
    }
}

其中:

  • Deadline方法需要返回当前Context被取消的时间,也就是完成工作的截止时间(deadline);
  • Done方法需要返回一个Channel,这个Channel会在当前工作完成或者上下文被取消之后关闭,多次调用Done方法会返回同一个Channel;
  • Err方法会返回当前Context结束的原因,它只会在Done返回的Channel被关闭时才会返回非空的值;
    • 如果当前Context被取消就会返回Canceled错误;
    • 如果当前Context超时就会返回DeadlineExceeded错误;
  • Value方法会从Context中返回键对应的值,对于同一个上下文来说,多次调用Value 并传入相同的Key会返回相同的结果,该方法仅用于传递跨API和进程间跟请求域的数据;

内置函数Background()和TODO()

Go内置两个函数:Background()和TODO(),这两个函数分别返回一个实现了Context接口的background和todo。

我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

Background()主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。

TODO(),它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。

background和todo本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。

Context的With系列函数

context包中定义了四个With系列函数。

对服务器的传入请求应该创建一个Context上下文,对服务器的传出调用应该接受一个Context上下文。它们之间的函数调用链必须传播 Context,可选择将其替换为使用 WithCancel、WithDeadline、WithTimeout 或 WithValue 创建的派生 Context。

当一个上下文 Context 被取消时,所有从它派生的上下文也被取消。

WithCancel

WithCancel的函数签名如下:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func generate(ctx context.Context) <-chan int {
    
    
        dst := make(chan int)
        n := 1
        go func() {
    
    
            for {
    
    
                select {
    
    
                case <-ctx.Done():
                    return // return结束该goroutine,防止泄露
                case dst <- n:
                    n++
                }
            }
        }()
        return dst
    }

func main() {
    
    
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 当我们取完需要的整数后调用cancel

    for n := range generate(ctx) {
    
    
        fmt.Println(n)
        if n == 5 {
    
    
            break
        }
    }
}

上面的示例代码中,generate函数在单独的goroutine中生成整数,并将它们发送到返回的通道。

generate的调用者在使用生成的整数之后需要取消上下文,以免generate启动的内部goroutine发生泄漏。

WithDeadline

WithDeadline的函数签名如下:

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

返回父上下文的副本,并将deadline调整为不迟于d。

如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。

当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func main() {
    
    
    d := time.Now().Add(50 * time.Millisecond)
    ctx, cancel := context.WithDeadline(context.Background(), d)

    // 尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践。
    // 如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
    defer cancel()

    select {
    
    
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err())
    }
}

上面的代码中,定义了一个50毫秒之后过期的deadline,

然后我们调用context.WithDeadline(context.Background(), d) 得到一个上下文(ctx)和一个取消函数cancel,然后使用一个select让主程序陷入等待:等待1秒后打印overslept退出,或者等待ctx过期后退出。

因为ctx50秒后就过期,所以ctx.Done()会先接收到值,上面的代码会打印ctx.Err()取消原因。

WithTimeout

WithTimeout的函数签名如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout返回WithDeadline(parent, time.Now().Add(timeout))

取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。

具体示例如下:

package main

import (
    "context"
    "fmt"
    "sync"

    "time"
)

// context.WithTimeout

var wg sync.WaitGroup

func worker(ctx context.Context) {
    
    
LOOP:
    for {
    
    
        fmt.Println("db connecting ...")
        time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
        select {
    
    
        case <-ctx.Done(): // 50毫秒后自动调用
            break LOOP
        default:
        }
    }
    fmt.Println("worker done!")
    wg.Done()
}

func main() {
    
    
    // 设置一个50毫秒的超时
    ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
    wg.Add(1)
    go worker(ctx)
    time.Sleep(time.Second * 5)
    cancel() // 通知子goroutine结束
    wg.Wait()
    fmt.Println("over")
}

WithValue

WithValue函数能够将请求作用域的数据与 Context 对象建立关系。

WithValue声明如下:

 func WithValue(parent Context, key, val interface{
    
    }) Context

WithValue返回父节点的副本,其中与key关联的值为val。

下面的一个例子, 在所有的 协程之中,使用 context进行 日志编码 的传播。

package main

import (
    "context"
    "fmt"
    "sync"

    "time"
)

// context.WithValue

type TraceCode string

var wg sync.WaitGroup

func worker(ctx context.Context) {
    
    
    key := TraceCode("TRACE_CODE")
    traceCode, ok := ctx.Value(key).(string) // 在子goroutine中获取trace code
    if !ok {
    
    
        fmt.Println("invalid trace code")
    }
LOOP:
    for {
    
    
        fmt.Printf("worker, trace code:%s\n", traceCode)
        time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
        select {
    
    
        case <-ctx.Done(): // 50毫秒后自动调用
            break LOOP
        default:
        }
    }
    fmt.Println("worker done!")
    wg.Done()
}

func main() {
    
    
    // 设置一个50毫秒的超时
    ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
    // 在系统的入口中设置trace code传递给后续启动的goroutine实现日志数据聚合
    ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "12512312234")
    wg.Add(1)
    go worker(ctx)
    time.Sleep(time.Second * 5)
    cancel() // 通知子goroutine结束
    wg.Wait()
    fmt.Println("over")
}

上面的 break LOOP,表示跳出循环

Go语言中 break 语句可以结束 forswitchselect 的代码块,

break 语句可以在语句后面添加标签,表示退出某个标签对应的代码块,
 
break 后面的标签,要求必须定义在对应的 forswitchselect 的代码块上。

最后说一下: context.WithValue上下文所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。context.WithValue上下文的键,应该是用户定义自己的类型。

使用Context的注意事项

  • 规则1:推荐以参数的方式显示传递Context
  • 规则2:以Context作为参数的函数方法,应该把Context作为第一个参数。
  • 规则3:给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
  • 规则4:Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
  • 规则5:Context是线程安全的,可以放心的在多个goroutine中传递

尼恩提示: 在咱们的案例中,为了编码的简单,不是以参数的方式显示传递Context,而是用封装的方式放在了service基类里边,这点,违背了 规则1.

总之,咱们的案例,违背了 规则1.

业务CRUD的Dao层设计与开发

Dao层的基础类

package dao

import "github.com/jinzhu/gorm"

type Dao struct {
    
    
	engine *gorm.DB
}

func New(engine *gorm.DB) *Dao {
    
    
	return &Dao{
    
    engine: engine}
}

注意这个 engine 是外部注入的,主要在外部进行 构建。

什么时候创建的 gorm.DB ORM engine 对象呢? 是在构建 service的时候:

type Service struct {
    
    
	ctx context.Context
	dao *dao.Dao
}

func New(ctx context.Context) Service {
    
    
	svc := Service{
    
    ctx: ctx}
	//svc.dao = dao.New(global.DBEngine)
	svc.dao = dao.New(otgorm.WithContext(svc.ctx, global.DBEngine))
	return svc
}

在构建 service的时候,构建了 一个dao对象,并且把 一个全局的 gorm 数据持久化组件对象,作为参数进行的注入。

再看这个全局的 global.DBEngine 对象的定义:

package global

import (
	"github.com/elastic/go-elasticsearch/v8"
	rdb "github.com/go-redis/redis/v8"
	"github.com/gomodule/redigo/redis"
	"github.com/jinzhu/gorm"
	"github.com/opentracing/opentracing-go"
	"github.com/sirupsen/logrus"
	"go.mongodb.org/mongo-driver/mongo"
)

var (
	DBEngine     *gorm.DB
	RedisPool    *redis.Pool
	Redis        *rdb.Client
	Logger       *logrus.Logger
	Tracer       opentracing.Tracer
	Es           *elasticsearch.Client
	Mongo        *mongo.Client
	ModelPath    string
	ModelReplace string
)

global.DBEngine 对象的初始化

这个全局的 global.DBEngine 对象的初始化,在model 模块的model 基础类中。

package model

import (
	"fmt"
	"crazymakercircle.com/gin-rest/common/global"
	"crazymakercircle.com/gin-rest/pkg/toolkit/cast"
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/mysql"
	log "github.com/sirupsen/logrus"
	"time"

	cfg "crazymakercircle.com/gin-rest/internal/config"
)

func Init() {
    
    
	var err error
	//关于读写分离我们可以定义两个变量,一个读和一个写的,
	//然后分别初始化,
	//然后在查询和写入操作的时候使用不同的连接,或者使用一个map保存读和写的连接
	global.DBEngine, err = NewDBEngine()
	if err != nil {
    
    
		panic(err)
	}
}

func NewDBEngine() (*gorm.DB, error) {
    
    
	dsn := cfg.AppDbDsn.Load()
	driver := cfg.AppDbDriver.Load()
	switch driver {
    
    
	case "mysql":
		log.Printf("%s:%s",
			driver,
			dsn)
	default:
		log.Fatalf("invalid db driver %v\n", cfg.AppDbDriver.Load())
	}

	db, err := gorm.Open("mysql", dsn)
	if err != nil {
    
    
		log.Fatalf("Open "+cfg.AppDbDriver.Load()+" failed. %v\n", err)
		return nil, err
	}
	db.LogMode(true)
	db.DB().SetConnMaxLifetime(cast.ToDuration(cfg.AppDbMaxLifetime.Load())) //最大连接周期,超过时间的连接就close
	db.DB().SetMaxOpenConns(cast.ToInt(cfg.AppDbMaxOpens.Load()))            //设置最大连接数
	db.DB().SetMaxIdleConns(cast.ToInt(cfg.AppDbMaxIdles.Load()))            //设置闲置连接数
	//设置全局表名禁用复数
	db.SingularTable(true)

	return db, nil
}

.....

model领域对象层

model层,类似SpringMVC 中的PO层。

下面是model层的一个类:

package model

import (
	"crazymakercircle.com/gin-rest/pkg/app"
	"github.com/jinzhu/gorm"
)

type ArticleSwagger struct {
    
    
	List  []*Article
	Pager *app.Pager
}

type Article struct {
    
    
	Id           uint64 `json:"id"`
	Title        string `json:"title"`
	Content      string `json:"content"`
	Introduction string `json:"introduction"`
	Views        int    `json:"views"`
	CreatedAt    string `json:"created_at"`
	UpdatedAt    string `json:"-"`
}

func (a Article) TableName() string {
    
    
	return "article"
}

func (a Article) Count(db *gorm.DB) (int, error) {
    
    
	var count int
	if a.Title != "" {
    
    
		db.Where("title like ?", "%"+a.Title+"%")
	}
	if err := db.Model(&a).Count(&count).Error; err != nil {
    
    
		return 0, err
	}
	return count, nil
}

func (a Article) List(db *gorm.DB, pageOffset, pageSize int) ([]*Article, error) {
    
    
	var list []*Article
	if a.Title != "" {
    
    
		db.Where("title like ?", "%"+a.Title+"%")
	}
	err := db.Limit(pageSize).Offset(pageOffset).Find(&list).Error
	return list, err
}

注意,这个和springmvc不同:

  • springmvc 的业务模型(领域对象)是贫血模型, 没有对数据库的任何操作的
  • 这里的model领域对象,是充血模型

贫血模型是指model对象只用于在各层之间传输数据使用,只有数据字段和Get/Set方法,没有逻辑在对象中。

充血模型是面向对象设计的本质,一个对象是拥有状态和行为的。将大多数业务逻辑和持久化放在领域对象中,业务逻辑只是完成对业务逻辑的封装、事务、权限、校验等的处理。

当然, 这里仅仅是套用一下DDD里边的概念。 和DDD的模式,并不是完全一致。

这里的Dao层对数据的访问进行了弱化, 很多的orm 数据访问逻辑, 委托到了 model 领域对象层。

使用GORM 链式操作,完成orm持久层的访问

上面的代码在访问数据库的时候,使用了 GORM 链式操作。

比如,在Count方法中获取Article 数据的时候,就是使用GORM 链式操作,具体如下:

func (a Article) Count(db *gorm.DB) (int, error) {
    
    
	var count int
	if a.Title != "" {
    
    
		db.Where("title like ?", "%"+a.Title+"%")
	}
	if err := db.Model(&a).Count(&count).Error; err != nil {
    
    
		return 0, err
	}
	return count, nil
}

GORM 允许进行链式操作,所以大家可以像这样写代码:

db.Where("name = ?", "jinzhu").Where("age = ?", 18).First(&user)

GORM 中有三种类型的方法: 链式方法、终结方法、新建会话方法。

链式方法

链式方法是将 Clauses(sql字句) 修改或添加到当前Statement 的方法,例如:

Where, Select, Omit, Joins, Scopes, Preload, 等

db.Table("users").Select("name, email").Where("age > ?", 18).Find(&users)

上面的 Select、Where 都是链式方法

终结方法(Finisher )

Finishers 是会立即执行注册回调的方法,然后生成并执行 SQL,比如这些方法:

Create, First, Find, Take, Save, Update, Delete, Scan, Row, Rows… 等

db.Table("users").Select("name, email").Where("age > ?", 18).Find(&users)

上面的Find 都是终结方法, 会生成并执行 SQL。

下面是几个 终结方法(Finisher )的示例:

// 获取第一条记录,按主键排序
db.First(&user)
//生成的SQL: SELECT * FROM users ORDER BY id LIMIT 1;

// 获取一条记录,不指定排序
db.Take(&user)
//生成的SQL: SELECT * FROM users LIMIT 1;

// 获取最后一条记录,按主键排序
db.Last(&user)
//生成的SQL: SELECT * FROM users ORDER BY id DESC LIMIT 1;

// 获取所有的记录
db.Find(&users)
//生成的SQL: SELECT * FROM users;

// 通过主键进行查询 (仅适用于主键是数字类型)
db.First(&user, 10)
//生成的SQL: SELECT * FROM users WHERE id = 10;

新建会话方法

在 链式方法, 终结方法之后, GORM 返回一个初始化的*gorm.DB实例。需要注意的是, *gorm.DB不能安全地重复使用,并且新生成的 SQL 可能会被先前的条件污染,例如:

queryDB := DB.Where("name = ?", "jinzhu")

queryDB.Where("age > ?", 10).First(&user)
//生成的SQL: SELECT * FROM users WHERE name = "jinzhu" AND age > 10

queryDB.Where("age > ?", 20).First(&user2)
//生成的SQL: SELECT * FROM users WHERE name = "jinzhu" AND age > 10 AND age > 20

上面的代码中第二个where生成的sql,明显不需要前面where的条件,所以,后面的新生成的 SQL 被先前的条件污染。

为了重新使用初始化的 *gorm.DB 实例, 可以使用 新建会话方法, 创建一个可共享的 *gorm.DB, 例如:

queryDB := DB.Where("name = ?", "jinzhu").Session(&gorm.Session{
    
    })
queryDB.Where("age > ?", 10).First(&user)
// SELECT * FROM users WHERE name = "jinzhu" AND age > 10
queryDB.Where("age > ?", 20).First(&user2)
// SELECT * FROM users WHERE name = "jinzhu" AND age > 20

关于gorm 的 session介绍,请参见下面的链接:

https://gorm.cn/zh_CN/docs/session.html

编写公共组件

每个项目中,都会有一类组件,我们常称其为基础组件,又或是公共组件,它们是不带强业务属性的,串联着整个应用程序.

公共组件由架构师、高级开发或技术中台团队的同事进行梳理和编写,并且负责统一维护,如果集中统一的管理,每个业务团队都写一套,是非常糟糕的,既带来重复建设重复开发,也会带来不同业务团队代码复用和移植的难度。

公共组件其实非常多,这里简单的介绍几个。

错误处理组件

在应用程序的运行中,我们常常需要与客户端进行交互,而交互分别是两点,一个是正确响应下的结果集返回,另外一个是错误响应的错误码和消息体返回,用于告诉客户端,这一次请求发生了什么事,因为什么原因失败了。

因此在一个新项目搭建之初,其中重要的一项预备工作,那就是标准化我们的错误码格式,保证客户端是“理解”我们的错误码规则,不需要每次都写一套新的。

定义公共错误码

在项目目录下的 pkg/errcode 目录新建 common_code.go 文件,用于预定义项目中的一些公共错误码,便于引导和规范大家的使用,如下:

var (
    Success                   = NewError(0, "成功")
    ServerError               = NewError(10000000, "服务内部错误")
    InvalidParams             = NewError(10000001, "入参错误")
    NotFound                  = NewError(10000002, "找不到")
    UnauthorizedAuthNotExist  = NewError(10000003, "鉴权失败,找不到对应的 AppKey 和 AppSecret")
    UnauthorizedTokenError    = NewError(10000004, "鉴权失败,Token 错误")
    UnauthorizedTokenTimeout  = NewError(10000005, "鉴权失败,Token 超时")
    UnauthorizedTokenGenerate = NewError(10000006, "鉴权失败,Token 生成失败")
    TooManyRequests           = NewError(10000007, "请求过多")
)

定义处理公共方法

在项目目录下的 pkg/errcode 目录新建 errcode.go 文件,编写常用的一些错误处理公共方法,标准化我们的错误输出,如下:

type Error struct {
    
    
    code int `json:"code"`
    msg string `json:"msg"`
    details []string `json:"details"`
}
var codes = map[int]string{
    
    }
func NewError(code int, msg string) *Error {
    
    
    if _, ok := codes[code]; ok {
    
    
        panic(fmt.Sprintf("错误码 %d 已经存在,请更换一个", code))
    }
    codes[code] = msg
    return &Error{
    
    code: code, msg: msg}
}
func (e *Error) Error() string {
    
    
    return fmt.Sprintf("错误码:%d, 错误信息::%s", e.Code(), e.Msg())
}
func (e *Error) Code() int {
    
    
    return e.code
}
func (e *Error) Msg() string {
    
    
    return e.msg
}
func (e *Error) Msgf(args []interface{
    
    }) string {
    
    
    return fmt.Sprintf(e.msg, args...)
}
func (e *Error) Details() []string {
    
    
    return e.details
}
func (e *Error) WithDetails(details ...string) *Error {
    
    
    newError := *e
    newError.details = []string{
    
    }
    for _, d := range details {
    
    
        newError.details = append(newError.details, d)
    }
    return &newError
}
func (e *Error) StatusCode() int {
    
    
    switch e.Code() {
    
    
    case Success.Code():
        return http.StatusOK
    case ServerError.Code():
        return http.StatusInternalServerError
    case InvalidParams.Code():
        return http.StatusBadRequest
    case UnauthorizedAuthNotExist.Code():
        fallthrough
    case UnauthorizedTokenError.Code():
        fallthrough
    case UnauthorizedTokenGenerate.Code():
        fallthrough
    case UnauthorizedTokenTimeout.Code():
        return http.StatusUnauthorized
    case TooManyRequests.Code():
        return http.StatusTooManyRequests
    }
    return http.StatusInternalServerError
}

在错误码方法的编写中,我们声明了 Error 结构体用于表示错误的响应结果,并利用 codes 作为全局错误码的存储载体,便于查看当前注册情况,并在调用 NewError 创建新的 Error 实例的同时进行排重的校验。

另外相对特殊的是 StatusCode 方法,它主要用于针对一些特定错误码进行状态码的转换,因为不同的内部错误码在 HTTP 状态码中都代表着不同的意义,我们需要将其区分开来,便于客户端以及监控/报警等系统的识别和监听。

公共日志组件

一般来说,应用代码中,都是直接使用 Go 标准库 log 来进行的日志输出。

使用 标准库 log 来进行的日志输出有啥问题呢?

在一个项目中,我们的日志需要标准化的记录一些的公共信息,例如:代码调用堆栈、请求链路 ID、公共的业务属性字段等等,而直接输出标准库的日志的话,并不具备这些数据,也不够灵活。

日志的信息的齐全与否在排查和调试问题中是非常重要的一环,因此在应用程序中我们也会有一个标准的日志组件会进行统一处理和输出。

logrus 日志组件的使用

logrus日志库是一个结构化、插件化的日志记录库。

完全兼容 golang 标准库中的日志模块。

它还内置了 2 种日志输出格式 JSONFormatter 和 TextFormatter,来定义输出的日志格式。

github地址:https://github.com/sirupsen/logrus

这里使用 logrus 日志组件 作为基础组件。

日志组件初始化

在项目目录下的 pkg/ 目录新建 logger 目录,并创建 logrus.go 文件,写入日志组件初始化的代码:

package logger

import (
	"crazymakercircle.com/gin-rest/common/global"
	"crazymakercircle.com/gin-rest/internal/config"
	"crazymakercircle.com/gin-rest/pkg/helper/files"
	"github.com/evalphobia/logrus_sentry"
	"github.com/sirupsen/logrus"
)

func Init() {
    
    
	global.Logger = logrus.New()
	if config.AppSentryDsn.Load() != "" {
    
    
		hook, err := logrus_sentry.NewSentryHook(config.AppSentryDsn.Load(), []logrus.Level{
    
    
			logrus.PanicLevel,
			logrus.FatalLevel,
			logrus.ErrorLevel,
		})
		if err == nil {
    
    
			global.Logger.Hooks.Add(hook)
			hook.Timeout = 0
			hook.StacktraceConfiguration.Enable = true
		}
	}
	// 设置日志格式为json格式
	global.Logger.SetFormatter(&logrus.JSONFormatter{
    
    })
	//设置文件输出
	f, logFilePath := files.LogFile()
	// 日志消息输出可以是任意的io.writer类型,这里我们获取文件句柄,将日志输出到文件
	global.Logger.SetOutput(f)
	// 设置日志级别为debug以上
	global.Logger.SetLevel(logrus.DebugLevel)
	// 设置显示文件名和行号
	global.Logger.SetReportCaller(true)
	// 设置rotatelogs日志分割Hook
	global.Logger.AddHook(NewLfsHook(logFilePath))

}

包全局变量

在完成日志库的编写后,我们需要定义一个 Logger 对象便于我们的应用程序使用。

因此我们打开项目目录下的 common/global/global.go 文件,新增如下内容:

var (
    ...
   	Logger       *logrus.Logger

)

我们在包全局变量中新增了 Logger 对象,用于日志组件的初始化。

分页响应处理

在项目目录下的 pkg/app 目录下新建 pagination.go 文件,如下:

package app

import (
	"crazymakercircle.com/gin-rest/internal/config"
	"crazymakercircle.com/gin-rest/pkg/toolkit/cast"
	"crazymakercircle.com/gin-rest/pkg/toolkit/convert"
	"github.com/gin-gonic/gin"
)

func GetPage(c *gin.Context) int {
    
    
	page := convert.Str(c.Query("page")).ToInt()
	if page <= 0 {
    
    
		return 1
	}

	return page
}

func GetPageSize(c *gin.Context) int {
    
    
	pageSize := convert.Str(c.Query("page_size")).ToInt()
	if pageSize <= 0 {
    
    
		return cast.ToInt(config.AppDefaultPageSize.Load())
	}
	return pageSize
}

func GetPageOffset(page, pageSize int) int {
    
    
	result := 0
	if page > 0 {
    
    
		result = (page - 1) * pageSize
	}

	return result
}

响应处理

在项目目录下的 pkg/app 目录下新建 app.go 文件,如下:

package app

import (
	"bytes"
	"crazymakercircle.com/gin-rest/common/dict"
	"crazymakercircle.com/gin-rest/pkg/helper/gjson"
	"github.com/gin-gonic/gin"
	"io/ioutil"
	"net/http"
	"time"
)

type Response struct {
    
    
	Code    int         `json:"code"`
	Msg     string      `json:"msg"`
	Data    interface{
    
    } `json:"data"`
	Elapsed float64     `json:"elapsed"`
}

type Pager struct {
    
    
	Page      int `json:"page"`
	PageSize  int `json:"page_size"`
	TotalRows int `json:"total_rows"`
}

//Success 正常返回
func Success(c *gin.Context, data interface{
    
    }) {
    
    
	if data == nil {
    
    
		data = make([]string, 0)
	}
	response := Response{
    
    Code: 0, Msg: "success", Data: data, Elapsed: GetElapsed(c)}
	c.Set("responseData", response)
	c.JSON(http.StatusOK, response)
}

//SuccessList 分页返回
func SuccessList(c *gin.Context, list interface{
    
    }, totalRows int) {
    
    
	data := gin.H{
    
    
		"list": list,
		"pager": Pager{
    
    
			Page:      GetPage(c),
			PageSize:  GetPageSize(c),
			TotalRows: totalRows,
		},
	}
	e := dict.Success
	response := Response{
    
    Code: e.Code(), Msg: e.Msg(), Data: data, Elapsed: GetElapsed(c)}
	c.Set("responseData", response)
	c.JSON(http.StatusOK, response)
}

//Error 使用公共配置的消息返回
func Error(c *gin.Context, err *dict.Error) {
    
    
	response := Response{
    
    Code: err.Code(), Msg: err.Msg(), Elapsed: GetElapsed(c)}
	details := err.Details()
	if err.Level() == "" {
    
     //默认错误返回为warn,不记录日志到sentry
		err = err.WithLevel("warn")
	}
	SetLevel(c, err.Level())
	if len(details) > 0 {
    
    
		SetDetail(c, err.Details())
		if err.Level() != dict.LevelError {
    
    
			response.Data = details
		}
	}
	c.Set("responseData", response)
	c.JSON(err.StatusCode(), response)
}

func SetLevel(c *gin.Context, level interface{
    
    }) {
    
    
	c.Set("level", level)
}

func SetDetail(c *gin.Context, detail interface{
    
    }) {
    
    
	c.Set("detail", detail)
}

func GetLevel(c *gin.Context) interface{
    
    } {
    
    
	return Get(c, "level")
}

func GetDetail(c *gin.Context) interface{
    
    } {
    
    
	return Get(c, "detail")
}

func Get(c *gin.Context, key string) interface{
    
    } {
    
    
	val, _ := c.Get(key)
	return val
}

func GetElapsed(c *gin.Context) float64 {
    
    
	elapsed := 0.00
	if requestTime := Get(c, "beginTime"); requestTime != nil {
    
    
		elapsed = float64(time.Since(requestTime.(time.Time))) / 1e9
	}
	return elapsed
}
func JsonParams(c *gin.Context) map[string]interface{
    
    } {
    
    
	b, err := ioutil.ReadAll(c.Request.Body)
	if err != nil {
    
    
		panic(err)
	}
	// 将取出来的body内容重新插入body,否则ShouldBindJSON无法绑定参数
	c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(b))
	return gjson.JsonDecode(string(b))
}

func Params(c *gin.Context) string {
    
    
	b, err := ioutil.ReadAll(c.Request.Body)
	if err != nil {
    
    
		panic(err)
	}
	// 将取出来的body内容重新插入body,否则ShouldBindJSON无法绑定参数
	c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(b))
	return string(b)
}

分页返回的使用

我们可以找到其中一个接口方法,调用对应的方法,如下:

func (a *Article) ArticleList(c *gin.Context) {
    
    
	param := struct {
    
    
		Title string `form:"title" binding:"max=100"`
	}{
    
    }
	valid, errs := app.BindAndCheck(c, &param)
	if !valid {
    
    
		app.Error(c, dict.InvalidParams.WithDetails(errs.Errors()...))
		return
	}
	pager := app.Pager{
    
    Page: app.GetPage(c), PageSize: app.GetPageSize(c)}

	svc := service.New(c.Request.Context())
	totalRows, err := svc.CountArticle(param.Title)
	if err != nil {
    
    
		app.Error(c, dict.ErrGetArtCountFail)
		return
	}
	articles, err := svc.GetArticleList(param.Title, &pager)
	if err != nil {
    
    
		app.Error(c, dict.ErrGetArtListFail)
		return
	}
	for _, article := range articles {
    
    
		num, err := redigo.GetNum("article" + strconv.Itoa(int(article.Id)))
		if err != nil {
    
    
			num = 1
		}
		article.Views += num
	}
	//分页返回的使用
	app.SuccessList(c, articles, totalRows)
	return
}

Swagger接口文档

如何维护rest api 接口文档,是绝大部分开发人员都经历过的问题,因为前端、后端、测试开发等等人员都要看,每个人都给一份的话,怎么维护,这将是一个非常头大的问题。

针对这类问题,市面上出现了大量的解决方案,Swagger 正是其中的佼佼者,它更加的全面和完善,具有相关联的生态圈。

它是基于标准的 OpenAPI 规范进行设计的,只要照着这套规范去编写你的注解或通过扫描代码去生成注解,就能生成统一标准的接口文档和一系列 Swagger 工具。

OpenAPI & Swagger

在上文我们有提到 OpenAPI,你可能会对此产生疑惑,OpenAPI 和 Swagger 又是什么关系?

其实 OpenAPI 规范是在 2015 年由 OpenAPI Initiative 捐赠给 Linux 基金会的,并且 Swagger 对此更进一步的针对 OpenAPI 规范提供了大量与之相匹配的工具集,能够充分利用 OpenAPI 规范去映射生成所有与之关联的资源和操作去查看和调用 RESTful 接口,因此我们也常说 Swagger 不仅是一个“规范”,更是一个框架。

从功能使用上来讲,OpenAPI 规范能够帮助我们描述一个 API 的基本信息,比如:

  • 有关该 API 的描述。
  • 可用路径(/资源)。
  • 在每个路径上的可用操作(获取/提交…)。
  • 每个操作的输入/输出格式。

安装 Swagger

Swagger 相关的工具集会根据 OpenAPI 规范去生成各式各类的与接口相关联的内容,常见的流程是编写注解 =》调用生成库-》生成标准描述文件 =》生成/导入到对应的 Swagger 工具。

因此接下来第一步,我们要先安装 Go 对应的开源 Swagger 相关联的库,在项目 blog-service 根目录下执行安装命令,如下:

$ go get -u github.com/swaggo/swag/cmd/[email protected]
$ go get -u github.com/swaggo/[email protected] 
$ go get -u github.com/swaggo/files
$ go get -u github.com/alecthomas/template

验证是否安装成功,如下:

$ swag -vswag version v1.6.5

如果命令行提示寻找不到 swag 文件,可以检查一下对应的 bin 目录是否已经加入到环境变量 PATH 中。

写入注解

在完成了 Swagger 关联库的安装后,我们需要针对项目里的 API 接口进行注解的编写,以便于后续在进行生成时能够正确的运行,接下来我们将使用到如下注解:

注解 描述
@Summary 摘要
@Produce API 可以产生的 MIME 类型的列表,MIME 类型你可以简单的理解为响应类型,例如:json、xml、html 等等
@Param 参数格式,从左到右分别为:参数名、入参类型、数据类型、是否必填、注释
@Success 响应成功,从左到右分别为:状态码、参数类型、数据类型、注释
@Failure 响应失败,从左到右分别为:状态码、参数类型、数据类型、注释
@Router 路由,从左到右分别为:路由地址,HTTP 方法

swagger注解的编写

我们切换到项目目录下的 internal/routers/api/ 目录,打开 article.go 文件,在 ArticleList 方法前面,加上swagger注解:

// ArticleList
// @Summary 获取列表
// @Produce  json
// @Param name query string false "名称" maxlength(100)
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} model.Article"成功"
// @Failure 400 {object} dict.Error "请求错误"
// @Failure 500 {object} dict.Error "内部错误"
// @Router /api/articles [get]
func (a *Article) ArticleList(c *gin.Context) {
    
    

在这里我们只展示了一个接口注解编写,接下来你应当按照注解的含义和参考上述接口注解,完成其他接口注解的编写。

swagger 配置文件的生成

在完成了所有的注解编写后,我们回到项目根目录下,执行如下命令:

$ swag init

在执行命令完毕后,会发现在 docs 文件夹生成 docs.go、swagger.json、swagger.yaml 三个文件。

swagger中间件的注册

那注解编写完,也通过 swag init 把 Swagger API 所需要的文件都生成了,那接下来我们怎么访问接口文档呢?

其实很简单,我们只需要在 routers 中进行默认初始化和注册对应的路由就可以了,打开项目目录下的 internal/routers 目录中的 router.go 文件,新增代码如下:

import (
    ...
    _ "github.com/go-programming-tour-book/blog-service/docs"
    ginSwagger "github.com/swaggo/gin-swagger"
    "github.com/swaggo/gin-swagger/swaggerFiles"
)
func NewRouter() *gin.Engine {
    
    
    r := gin.New()
    r.Use(gin.Logger())
    r.Use(gin.Recovery())
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    ...
    return r
}

从表面上来看,主要做了两件事,分别是初始化 docs 包和注册一个针对 swagger 的路由,而在初始化 docs 包后,其 swagger.json 将会默认指向当前应用所启动的域名下的 swagger/doc.json 路径,如果有额外需求,可进行手动指定,如下:

url := ginSwagger.URL("http://127.0.0.1:8000/swagger/doc.json")
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url))

通过swagger查看接口文档

http://localhost:9099/swagger/index.html

在完成了上述的设置后,我们重新启动服务端,在浏览器中访问 Swagger 的地址 ,就可以看到上述图片中的 Swagger 文档展示,

其主要分为三个部分,分别是项目主体信息、接口路由信息、模型信息,这三部分共同组成了我们主体内容。

Swagger 背后发生了什么

可能会疑惑,我明明只是初始化了个 docs 包并注册了一个 Swagger 相关路由,Swagger 的文档是怎么关联上的呢,我在接口上写的注解又到哪里去了?

其实主体是与swagger init 生成的文件有关的,分别是:

docs
├── docs.go
├── swagger.json
└── swagger.yaml

初始化 docs

在第一步中,我们初始化了 docs 包,对应的其实就是 docs.go 文件,因为目录下仅有一个 go 源文件,其源码如下:

package docs

import (
	"bytes"
	"encoding/json"
	"strings"

	"github.com/alecthomas/template"
	"github.com/swaggo/swag"
)

var doc = `{
    "schemes": {
    
    { marshal .Schemes }},
    "swagger": "2.0",
    "info": {
        "description": "{
    
    {.Description}}",
        "title": "{
    
    {.Title}}",
        "contact": {},
        "license": {},
        "version": "{
    
    {.Version}}"
    },
    "host": "{
    
    {.Host}}",
    "basePath": "{
    
    {.BasePath}}",
    "paths": {
        "/api/articles": {
            "get": {
                "produces": [
                    "application/json"
                ],
                "summary": "获取列表",
                "parameters": [
                    {
                        "maxLength": 100,
                        "type": "string",
                        "description": "名称",
                        "name": "name",
                        "in": "query"
                    },
                    {
                        "enum": [
                            0,
                            1
                        ],
                        "type": "integer",
                        "default": 1,
                        "description": "状态",
                        "name": "state",
                        "in": "query"
                    },
                    {
                        "type": "integer",
                        "description": "页码",
                        "name": "page",
                        "in": "query"
                    },
                    {
                        "type": "integer",
                        "description": "每页数量",
                        "name": "page_size",
                        "in": "query"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "成功",
                        "schema": {
                            "$ref": "#/definitions/model.Article"
                        }
                    },
                    "400": {
                        "description": "请求错误",
                        "schema": {
                            "$ref": "#/definitions/dict.Error"
                        }
                    },
                    "500": {
                        "description": "内部错误",
                        "schema": {
                            "$ref": "#/definitions/dict.Error"
                        }
                    }
                }
            }
        }
    },
    "definitions": {
        "dict.Error": {
            "type": "object",
            "properties": {
                "code": {
                    "type": "integer"
                },
                "details": {
                    "type": "array",
                    "items": {
                        "type": "string"
                    }
                },
                "level": {
                    "type": "string"
                },
                "msg": {
                    "type": "string"
                }
            }
        },
        "model.Article": {
            "type": "object",
            "properties": {
                "content": {
                    "type": "string"
                },
                "id": {
                    "type": "integer"
                },
                "introduction": {
                    "type": "string"
                },
                "title": {
                    "type": "string"
                }
            }
        }
    }
}`

type swaggerInfo struct {
    
    
	Version     string
	Host        string
	BasePath    string
	Schemes     []string
	Title       string
	Description string
}

// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = swaggerInfo{
    
    
	Version:     "1.0",
	Host:        "",
	BasePath:    "",
	Schemes:     []string{
    
    },
	Title:       "gin系统",
	Description: "gin开发的系统",
}

type s struct{
    
    }

func (s *s) ReadDoc() string {
    
    
	sInfo := SwaggerInfo
	sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1)

	t, err := template.New("swagger_info").Funcs(template.FuncMap{
    
    
		"marshal": func(v interface{
    
    }) string {
    
    
			a, _ := json.Marshal(v)
			return string(a)
		},
	}).Parse(doc)
	if err != nil {
    
    
		return doc
	}

	var tpl bytes.Buffer
	if err := t.Execute(&tpl, sInfo); err != nil {
    
    
		return doc
	}

	return tpl.String()
}

func init() {
    
    
	swag.Register(swag.Name, &s{
    
    })
}

通过对源码的分析,我们可以得知实质上在初始化 docs 包时,会默认执行 init 方法,而在 init 方法中,会注册相关方法,主体逻辑是 swag 会在生成时去检索项目下的注解信息,然后将项目信息和接口路由信息按规范生成到包全局变量 doc 中去。

紧接着会在 ReadDoc 方法中做一些 template 的模板映射等工作,完善 doc 的输出。

swagger注册路由

在上一步中,我们知道了生成的注解数据源在哪,但是它们两者又是怎么关联起来的呢,实际上与我们调用的 ginSwagger.WrapHandler(swaggerFiles.Handler) 有关,如下:

func WrapHandler(h *webdav.Handler, confs ...func(c *Config)) gin.HandlerFunc {
    
    
    defaultConfig := &Config{
    
    URL: "doc.json"}
    ...
    return CustomWrapHandler(defaultConfig, h)
}

实际上在调用 WrapHandler 后,swag 内部会将其默认调用的 URL 设置为 doc.json,但你可能会纠结,明明我们生成的文件里没有 doc.json,这又是从哪里来的,我们接着往下看,如下:

func CustomWrapHandler(config *Config, h *webdav.Handler) gin.HandlerFunc {
    
    
      ...
        switch path {
    
    
        case "index.html":
            index.Execute(c.Writer, &swaggerUIBundle{
    
    
                URL: config.URL,
            })
        case "doc.json":
            doc, err := swag.ReadDoc()
            if err != nil {
    
    
                panic(err)
            }
            c.Writer.Write([]byte(doc))
            return
        default:
            h.ServeHTTP(c.Writer, c.Request)
        }
    }
}

在 CustomWrapHandler 方法中,我们可以发现一处比较经典 switch case 的逻辑。

在第一个 case 中,处理是的 index.html,这又是什么呢,其实你可以回顾一下,我们在先前是通过

http://localhost:9099/swagger/index.html

访问到 Swagger 文档的,对应的便是这里的逻辑。

为接口做参数校验

接下来我们将正式进行编码,在进行对应的业务模块开发时,第一步要考虑到的问题的就是如何进行入参校验,我们需要将整个项目,甚至整个团队的组件给定下来,形成一个通用规范,并完成标签模块的接口的入参校验。

validator 介绍

在本项目中我们将使用开源项目 go-playground/validator 作为我们的本项目的基础库,它是一个基于标签来对结构体和字段进行值验证的一个验证器。

那么,我们要单独引入这个库吗,其实不然,因为我们使用的 gin 框架,其内部的模型绑定和验证默认使用的是 go-playground/validator 来进行参数绑定和校验,使用起来非常方便。

在项目根目录下执行命令,进行安装:

$ go get -u github.com/go-playground/validator/v10

业务接口校验

接下来我们将正式开始对接口的入参进行校验规则的编写,也就是将校验规则写在对应的结构体的字段标签上,常见的标签含义如下:

标签 含义
required 必填
gt 大于
gte 大于等于
lt 小于
lte 小于等于
min 最小值
max 最大值
oneof 参数集内的其中之一
len 长度要求与 len 给定的一致

文章接口校验

我们回到项目的 internal/service 目录下的 article.go 文件,针对入参校验增加绑定/验证结构体。

这块与登录模块的验证规则差不多,主要是必填,长度最小、最大的限制,以及要求参数值必须在某个集合内的其中之一.

登录模块的验证规则, 回顾如下:

type LoginForm struct {
    
    
	Username string `json:"username" binding:"required" validate:"required"`
	Password string `json:"password" binding:"required" validate:"required"`
}

func Login(c *gin.Context) {
    
    
	var form LoginForm
	if err := c.ShouldBindWith(&form, binding.JSON); err != nil {
    
    
		c.JSON(http.StatusBadRequest, gin.H{
    
    "error": err.Error()})
		return
	}

	validate := validator.New()
	if err := validate.Struct(form); err != nil {
    
    
		c.JSON(http.StatusUnprocessableEntity, gin.H{
    
    "error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{
    
    
		"msg": "login",
	})
	// TODO: 处理登录逻辑
}

模块开发:商品管理

作为一个参考,接下来我们正式的进入一个业务模块的业务逻辑开发,商品管理 的开发,

首先进行 rest api 的设计, 涉及的api 接口如下:

功能 HTTP 方法 路径
新增商品 POST /seckillskus
删除指定商品 DELETE /seckillskus/:id
更新指定商品 PUT /seckillskus/:id
获取商品列表 GET /seckillskus

商品model 领域层开发

首先,根据数据库里边的表 seckill_sku,生产一个简单的 model

生产之后,放在了项目的 internal/model 目录下。

这个对应的model文件 seckillsku.go 文件。

接下来, 在 seckillsku.go 文件中添加 orm操作的方法,并且只添加与orm 实体相关的方法,代码如下:

package model

import (
	"github.com/jinzhu/gorm"
	"time"
)

type SeckillSku struct {
    
    
	SkuId      int64     `json:"sku_id"`     // 商品id
	CostPrice  float32   `json:"cost_price"` // 秒杀价格
	CreateTime time.Time `json:"create_time"`
	EndTime    time.Time `json:"end_time"`
	SkuImage   string    `json:"sku_image"`
	SkuPrice   float32   `json:"sku_price"` // 价格
	StartTime  time.Time `json:"start_time"`
	StockCount int       `json:"stock_count"` // 剩余库存
	SkuTitle   string    `json:"sku_title"`
	RawStock   int       `json:"raw_stock"`   // 原始库存
	ExposedKey string    `json:"exposed_key"` // 秒杀md5
}

func (s SeckillSku) TableName() string {
    
    
	return "seckill_sku"
}

func (s SeckillSku) Count(db *gorm.DB) (int, error) {
    
    
	var count int
	if s.SkuTitle != "" {
    
    
		db = db.Where("sku_title = ?", s.SkuTitle)
	}
	if err := db.Model(&s).Count(&count).Error; err != nil {
    
    
		return 0, err
	}
	return count, nil
}
func (s SeckillSku) List(db *gorm.DB, pageOffset, pageSize int) ([]*SeckillSku, error) {
    
    
	var SeckillSkus []*SeckillSku
	var err error
	if pageOffset >= 0 && pageSize > 0 {
    
    
		db = db.Offset(pageOffset).Limit(pageSize)
	}
	if s.SkuTitle != "" {
    
    
		db = db.Where("sku_title = ?", s.SkuTitle)
	}
	if err = db.Find(&SeckillSkus).Error; err != nil {
    
    
		return nil, err
	}
	return SeckillSkus, nil
}
func (s SeckillSku) Create(db *gorm.DB) error {
    
    
	return db.Create(&s).Error
}
func (s SeckillSku) Update(db *gorm.DB) error {
    
    
	return db.Model(&s).Where("sku_id = ? ", s.SkuId).Limit(1).Update(s).Error
}
func (s SeckillSku) Delete(db *gorm.DB) error {
    
    
	return db.Where("sku_id = ?", s.SkuId).Delete(&s).Error
}
  • Model:指定运行 DB 操作的模型实例,默认解析该结构体的名字为表名,格式为大写驼峰转小写下划线驼峰。若情况特殊,也可以编写该结构体的 TableName 方法用于指定其对应返回的表名。
  • Where:设置筛选条件,接受 map,struct 或 string 作为条件。
  • Offset:偏移量,用于指定开始返回记录之前要跳过的记录数。
  • Limit:限制检索的记录数。
  • Find:查找符合筛选条件的记录。
  • Updates:更新所选字段。
  • Delete:删除数据。
  • Count:统计行为,用于统计模型的记录数。

需要注意的是,在上述代码中,我们采取的是将 db *gorm.DB 作为函数首参数传入的方式,而在业界中也有另外一种方式,是基于结构体传入的,两者本质上都可以实现目的,读者根据实际情况(使用习惯、项目规范等)进行选用即可,其各有利弊。

商品 dao 层开发

我们在项目的 internal/dao 目录下新建 seckillSku.go 文件,写入如下代码:

package dao

import (
	"crazymakercircle.com/gin-rest/internal/model"
	"crazymakercircle.com/gin-rest/pkg/app"
)

func (d *Dao) CountSeckillSku(title string) (int, error) {
    
    
	tag := model.SeckillSku{
    
    SkuTitle: title}
	return tag.Count(d.engine)
}
func (d *Dao) GetSeckillSkuList(title string, page, pageSize int) ([]*model.SeckillSku, error) {
    
    
	seckillSku := model.SeckillSku{
    
    SkuTitle: title}
	pageOffset := app.GetPageOffset(page, pageSize)
	return seckillSku.List(d.engine, pageOffset, pageSize)
}
func (d *Dao) CreateSeckillSku(title string, rawStock int, createdBy string) error {
    
    
	tag := model.SeckillSku{
    
    
		SkuTitle: title,
		RawStock: rawStock,
	}
	return tag.Create(d.engine)
}
func (d *Dao) UpdateSeckillSku(id int64, title string) error {
    
    
	seckillSku := model.SeckillSku{
    
    
		SkuTitle: title,
		SkuId:    id,
	}
	return seckillSku.Update(d.engine)
}
func (d *Dao) DeleteSeckillSku(id int64) error {
    
    
	seckillSku := model.SeckillSku{
    
    SkuId: id}
	return seckillSku.Delete(d.engine)
}

在上述代码中,我们主要是在 dao 层进行了数据访问对象的封装,并针对业务所需的字段进行了处理。

商品 service 层开发

我们在项目的 internal/service 目录下新建 service.go 文件,写入如下代码:

type Service struct {
    
    
    ctx context.Context
    dao *dao.Dao
}
func New(ctx context.Context) Service {
    
    
    svc := Service{
    
    ctx: ctx}
    svc.dao = dao.New(global.DBEngine)
    return svc
}

接下来在同层级下新建 seckillSku.go 文件,用于处理商品模块的业务逻辑,写入如下代码:

package service

import (
	"crazymakercircle.com/gin-rest/internal/model"
	"crazymakercircle.com/gin-rest/pkg/app"
)

type SeckillSkuListRequest struct {
    
    
	title string `form:"title" binding:"max=100"`
}
type CreateSeckillSkuRequest struct {
    
    
	title    string `form:"title" binding:"required,max=100"`
	rawStock int    `form:"rawStock,default=1" binding:"required"`
}
type UpdateSeckillSkuRequest struct {
    
    
	ID    int64  `form:"id" binding:"required,gte=0"`
	title string `form:"title" binding:"required,max=100"`
}
type DeleteSeckillSkuRequest struct {
    
    
	ID int64 `form:"id" binding:"required,gte=0"`
}

func (svc *Service) CountSeckillSku(param *SeckillSkuListRequest) (int, error) {
    
    
	return svc.dao.CountSeckillSku(param.title)
}
func (svc *Service) GetSeckillSkuList(param *SeckillSkuListRequest, pager *app.Pager) ([]*model.SeckillSku, error) {
    
    
	return svc.dao.GetSeckillSkuList(param.title, pager.Page, pager.PageSize)
}
func (svc *Service) CreateSeckillSku(param *CreateSeckillSkuRequest) error {
    
    
	return svc.dao.CreateSeckillSku(param.title, param.rawStock)
}
func (svc *Service) UpdateSeckillSku(param *UpdateSeckillSkuRequest) error {
    
    
	return svc.dao.UpdateSeckillSku(param.ID, param.title)
}
func (svc *Service) DeleteSeckillSku(param *DeleteSeckillSkuRequest) error {
    
    
	return svc.dao.DeleteSeckillSku(param.ID)
}

service 主要是 调用了dao中的方法,当然,还可以在service 进行了一些简单的逻辑编写。

这里主要是为了演示,没有添加任何的业务逻辑。

新增业务错误码

我们在项目的 common/dict/errcode.go 文件,针对商品模块,写入如下错误代码:

var (
	ErrorGetSeckillSkuListFail = NewError(20010001, "获取商品列表失败")
	ErrorCreateSeckillSkuFail  = NewError(20010002, "创建商品失败")
	ErrorUpdateSeckillSkuFail  = NewError(20010003, "更新商品失败")
	ErrorDeleteSeckillSkuFail  = NewError(20010004, "删除商品失败")
	ErrorCountSeckillSkuFail   = NewError(20010005, "统计商品失败")
)

新增商品的handler方法

我们打开 internal/api/ 项目目录下的 seckillsku.go 文件,写入如下代码:

func (t SeckillSku) List(c *gin.Context) {
    
    
	param := service.SeckillSkuListRequest{
    
    }
	response := app.NewCtxResponse(c)
	valid, errs := app.BindAndValid(c, &param)
	if !valid {
    
    
		global.Logger.Errorf("app.BindAndValid errs: %v", errs)
		response.ToErrorResponse(dict.InvalidParams.WithDetails(errs.Errors()...))
		return
	}
	svc := service.New(c.Request.Context())
	pager := app.Pager{
    
    Page: app.GetPage(c), PageSize: app.GetPageSize(c)}
	totalRows, err := svc.CountSeckillSku(&service.SeckillSkuListRequest{
    
    Title: param.Title})
	if err != nil {
    
    
		global.Logger.Errorf("svc.CountSeckillSku err: %v", err)
		response.ToErrorResponse(dict.ErrorCountSeckillSkuFail)
		return
	}
	tags, err := svc.GetSeckillSkuList(&param, &pager)
	if err != nil {
    
    
		global.Logger.Errorf("svc.GetSeckillSkuList err: %v", err)
		response.ToErrorResponse(dict.ErrorGetSeckillSkuListFail)
		return
	}
	response.ToCtxResponseList(tags, totalRows)
	return
}

在上述代码中,我们完成了获取商品列表接口的处理方法,

我们在方法中完成了入参校验和绑定、获取商品总数、获取商品列表、 序列化结果集等四大功能板块的逻辑串联和日志、错误处理。

我们继续写入创建商品、更新商品、删除商品的接口处理方法,如下:

func (t SeckillSku) Create(c *gin.Context) {
    
    
	param := service.CreateSeckillSkuRequest{
    
    }
	response := app.NewCtxResponse(c)
	valid, errs := app.BindAndValid(c, &param)

	if !valid {
    
    
		global.Logger.Errorf("app.BindAndValid errs: %v", errs)
		response.ToErrorResponse(dict.InvalidParams.WithDetails(errs.Errors()...))
		return
	}
	svc := service.New(c.Request.Context())
	err := svc.CreateSeckillSku(&param)
	if err != nil {
    
    
		global.Logger.Errorf("svc.CreateSeckillSku err: %v", err)
		response.ToErrorResponse(dict.ErrorCreateSeckillSkuFail)
		return
	}
	app.Success(c, map[string]string{
    
    "title": c.Param("title")})
	return
}
func (t SeckillSku) Update(c *gin.Context) {
    
    
	response := app.NewCtxResponse(c)
	_, err := cast.ToInt64E(c.Param("id"))
	if err != nil {
    
    
		global.Logger.Errorf("svc.UpdateSeckillSku err: %v", err)
		response.ToErrorResponse(dict.ErrorUpdateSeckillSkuFail)
		return
	}
	param := service.UpdateSeckillSkuRequest{
    
    }
	valid, errs := app.BindAndValid(c, &param)
	if !valid {
    
    
		global.Logger.Errorf("app.BindAndValid errs: %v", errs)
		response.ToErrorResponse(dict.InvalidParams.WithDetails(errs.Errors()...))
		return
	}
	svc := service.New(c.Request.Context())
	err1 := svc.UpdateSeckillSku(&param)
	if err1 != nil {
    
    
		global.Logger.Errorf("svc.UpdateSeckillSku err: %v", err)
		response.ToErrorResponse(dict.ErrorUpdateSeckillSkuFail)
		return
	}

	app.Success(c, map[string]string{
    
    "id": c.Param("id")})
	return
}
func (t SeckillSku) Delete(c *gin.Context) {
    
    
	response := app.NewCtxResponse(c)
	_, err := cast.ToInt64E(c.Param("id"))
	if err != nil {
    
    
		global.Logger.Errorf("svc.Delete err: %v", err)
		response.ToErrorResponse(dict.ErrorUpdateSeckillSkuFail)
		return
	}
	param := service.DeleteSeckillSkuRequest{
    
    }
	valid, errs := app.BindAndValid(c, &param)
	if !valid {
    
    
		global.Logger.Errorf("app.BindAndValid errs: %v", errs)
		response.ToErrorResponse(dict.InvalidParams.WithDetails(errs.Errors()...))
		return
	}
	svc := service.New(c.Request.Context())
	err1 := svc.DeleteSeckillSku(&param)
	if err1 != nil {
    
    
		global.Logger.Errorf("svc.DeleteSeckillSku err: %v", err)
		response.ToErrorResponse(dict.ErrorDeleteSeckillSkuFail)
		return
	}
	app.Success(c, map[string]string{
    
    "id": c.Param("id")})
	return
}

新增商品的api路由

seckillSku := api.NewSeckillsku()
apiRouterGroup.GET("/seckillskus", seckillSku.List)
apiRouterGroup.GET("/seckillskus/:id", seckillSku.Detail)

验证商品接口

我们重新启动服务,也就是再执行 go run main.go,查看启动信息正常后,对商品模块的接口进行验证

(1)商品列表的验证

$ curl -X GET http://192.168.56.1:9099/api/seckillskus?page_size=2 

执行的效果如下

(2)验证获取商品

$ curl -X GET http://192.168.56.1:9099/api/seckillskus/1?title=demo

执行的效果如下

这里的获取商品详情的 DAO层、Service 层代码,都没有实现,留个大家自己去实现。

删除商品、删除商品的验证,也留给大家自己去实现。

另外注意:在更新的时候,当使用 GORM 中使用 struct 类型传入进行更新时,GORM 是不会对值为零值的字段进行变更。这又是为什么呢,根本的原因是因为在识别这个结构体中的这个字段值时,很难判定是真的是零值,还是外部传入恰好是该类型的零值,GORM 在这块并没有过多的去做特殊识别。

《Golang 圣经》还有 5W字待发布

本文,仅仅是《Golang 圣经》 的第4部分。《Golang 圣经》后面的内容 更加精彩,涉及到高并发、分布式微服务架构、 WEB开发架构

《Golang 圣经》PDF, 请到文末【技术自由圈】取 。

最后,如果学习过程中遇到问题,可以来尼恩的 万人高并发社区 中沟通。

参考资料

技术自由的实现路径 PDF:

实现你的 架构自由:

吃透8图1模板,人人可以做架构

10Wqps评论中台,如何架构?B站是这么做的!!!

阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了

峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?

100亿级订单怎么调度,来一个大厂的极品方案

2个大厂 100亿级 超大流量 红包 架构方案

… 更多架构文章,正在添加中

实现你的 响应式 自由:

响应式圣经:10W字,实现Spring响应式编程自由

这是老版本 《Flux、Mono、Reactor 实战(史上最全)

实现你的 spring cloud 自由:

Spring cloud Alibaba 学习圣经

分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)

一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)

实现你的 linux 自由:

Linux命令大全:2W多字,一次实现Linux自由

实现你的 网络 自由:

TCP协议详解 (史上最全)

网络三张表:ARP表, MAC表, 路由表,实现你的网络自由!!

实现你的 分布式锁 自由:

Redis分布式锁(图解 - 秒懂 - 史上最全)

Zookeeper 分布式锁 - 图解 - 秒懂

实现你的 王者组件 自由:

队列之王: Disruptor 原理、架构、源码 一文穿透

缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)

缓存之王:Caffeine 的使用(史上最全)

Java Agent 探针、字节码增强 ByteBuddy(史上最全)

实现你的 面试题 自由:

4000页《尼恩Java面试宝典 》 40个专题

尼恩 架构笔记、面试题 的PDF文件更新,请到下面《技术自由圈》公号取↓↓↓

猜你喜欢

转载自blog.csdn.net/crazymakercircle/article/details/131383065