代码地址:https://gitee.com/lymgoforIT/golang-trick/tree/master/37-load-local-cache
在上一节61.本地缓存加载与使用实践(活动管理系统:一)中,我们以活动元信息为例介绍了本地缓存的加载实践,本节想介绍一下另一个非常常见的编码技巧实践:状态机
之前有好几篇文章都介绍过状态机,但都有点单一,所以这次想举个具体的例子,结合Gin
搞个实践示例,当然,主要还是介绍编码套路,一些简单的CRUD
函数不会写的很详细
之前的文章地址如下:
22.有限状态机(一)go语言fsm库
23.有限状态机(二)状态模式实现
24.有限状态机(三)表驱动法Go实现
首先看下状态机要实现的效果
实现后代码目录结构如下
上节中是直接模拟的和DB
交互,本节我们就引入Gorm
和真实和DB
交互,代码比较简单,定义初始化DB
的方法,以及定义DB
常量以及获取它的GetDB
函数
package dal
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDB() error {
dsn := "root:root@(127.0.0.1:3306)/activity?charset=utf8mb4&parseTime=true&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
DB = db
return nil
}
func GetDB() *gorm.DB {
return DB
}
和Activity
对应的model
或表都写到该文件中,代码非常简单,就不过多介绍了,需要重点强调的是GetActivitiesByParam
函数,这也是工作中非常常用的一个编码技巧,对应下面小结中的第2
点。这里小结一下和DB
交互的几种常见方式吧!
ID
获取记录,常用于脚本或一些单一的查询。model
字段很像的结构体(常外加分页相关字段),根据结构体中各字段的有无拼SQL
,常用于元信息管理系统,有多个搜索框的页面。SQL
,且需要很强的扩展性的场景。package dal
import (
"errors"
"golang-trick/37-load-local-cache/model"
"time"
)
// GetActivity 从DB获取活动元信息
func GetActivitiesForLocalCache(minId int64, status []int, batchSize int) ([]*model.Activity, error) {
//return []*model.Activity{
// {
// Id: 1,
// Name: "限时返场",
// Type: 1, // 枚举更好,此处就把1当成返场类型
// ProductId: 1,
// Desc: "返场描述",
// Status: 1, // 枚举更好,此处就把1当成生效中
// Rules: "",
// StartTime: time.Time{},
// EndTime: time.Time{},
// CreateTime: time.Time{},
// UpdateTime: time.Time{},
// },
// {
// Id: 2,
// Name: "极速秒杀",
// Type: 2, // 秒杀类型
// ProductId: 1,
// Desc: "秒杀描述",
// Status: 1,
// Rules: "",
// StartTime: time.Time{},
// EndTime: time.Time{},
// CreateTime: time.Time{},
// UpdateTime: time.Time{},
// },
//}, nil
db := GetDB()
var activities []*model.Activity
db.Debug().Where("id >?", minId).Where("status in?", status).Limit(batchSize).Order("id").Find(&activities)
return activities, nil
}
func GetActivityById(id int64) (*model.Activity, error) {
db := GetDB()
var activity model.Activity
db.Debug().Where("id =?", id).Find(&activity)
return &activity, nil
}
// 较通用的方法,使用结构体做为参数,将不为空的字段拼到SQL中
type GetActivitiesParam struct {
Id int64 // 活动ID
Name string // 活动名称
Type int // 活动类型
ProductId int64 // 产品线
Desc string // 描述
Status []int // 活动状态
Rules string // 活动规则
StartTime *time.Time // 开始时间
EndTime *time.Time // 结束时间
PageNum int // 分页参数
PageSize int
}
func GetActivitiesByParam(param GetActivitiesParam) ([]*model.Activity, int64, error) {
db := GetDB()
out := make([]*model.Activity, 0)
if param.Id != 0 {
db = db.Where("id =?", param.Id)
}
if param.Name != "" {
db = db.Where("name =?", param.Name)
}
if param.Type != 0 {
db = db.Where("type =?", param.Type)
}
if param.ProductId != 0 {
db = db.Where("product_id =?", param.ProductId)
}
if param.Desc != "" {
db = db.Where("desc =?", param.Desc)
}
if len(param.Status) != 0 {
db = db.Where("status in?", param.Status)
}
if param.StartTime != nil {
db = db.Where("start_time >?", param.StartTime)
}
if param.EndTime != nil {
db = db.Where("end_time < ?", param.EndTime)
}
var total int64
// 如果传了合法的分页参数,则要进行分页查询,而分页查询一般都需要返回相应总条数,从而前端好分页显示并展示总条数以及页数
if param.PageNum != 0 && param.PageSize != 0 {
realPageNum := param.PageNum - 1
if realPageNum < 0 {
return nil, 0, errors.New("PageNum is invalid")
}
db.Model(model.Activity{}).Count(&total)
db = db.Order("id").Offset(realPageNum * param.PageSize).Limit(param.PageSize)
}
err := db.Find(&out).Error
return out, total, err
}
对于常量,我们一般习惯专门定义到相应的常量文件中,或者定义到要使用该常量的文件开头,这里选用了前者的方式,单独定义到文件中。
package constdef
// 活动状态的枚举
type ActivityStatusEnum int
const (
ActivityStatusEnum_Blank ActivityStatusEnum = 0 // 空白态
ActivityStatusEnum_Draft ActivityStatusEnum = 1 // 草稿
ActivityStatusEnum_OnlineApproval ActivityStatusEnum = 2 // 上线审批中
ActivityStatusEnum_OfflineApproval ActivityStatusEnum = 3 // 下线审批中
ActivityStatusEnum_Running ActivityStatusEnum = 4 // 运行中
ActivityStatusEnum_Stop ActivityStatusEnum = 5 // 已失效
ActivityStatusEnum_Testing ActivityStatusEnum = 6 // 测试中
)
在介绍路由前,首先介绍service
,因为路由不过就是调用service
里面的方法罢了,注意看注释哦,状态机以及其他的一些和Activity
相关的路由方法就在该文件中。
状态机以及SaveActivity方法
ActivityService
结构体里面包含了状态机,使用ActivityService
时都应该用·NewActivityService获得结构体对象,因为这个
new`方法中才会初始化状态机RegisterHandler
方法注册状态机handler
,也提供了GetHandlerByState
方法获取对应的handler
执行业务逻辑。具体的解释看代码和注释更为清晰。SaveActivity
方法中,保存、更新、提测、申请上线等都是调用这个方法就行,然后根据初态、次态获取对应的handler
执行handler
的逻辑,需要根据具体业务场景而定,这里就没有写了。比如创建需要考虑ID
的生成是否用ID
生成器,编辑需要校验编辑人是否有编辑权限,申请上线可能需要发起审批流水线等。此外该service
中还提供了与其他的一些路由相对应的方法,如
GetActivities
:根据条件获取活动列表GetActivityDetailById
:根据活动ID
获取活动详情OnlineApproval
:上线审批回调package service
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"golang-trick/37-load-local-cache/constdef"
"golang-trick/37-load-local-cache/dal"
"golang-trick/37-load-local-cache/model"
"net/http"
)
type ActivityHandler func(beforeActivityInfo, afterActivityInfo *model.Activity) error
type ActivityService struct {
// 该状态机是针对活动的,只会在该包下使用,所以作为非导出字段
stateMachine map[string]ActivityHandler
}
func NewActivityService() *ActivityService {
as := &ActivityService{
stateMachine: make(map[string]ActivityHandler),
}
// 注册状态机
as.RegisterHandler(constdef.ActivityStatusEnum_Blank, constdef.ActivityStatusEnum_Draft, as.CreateActivity)
as.RegisterHandler(constdef.ActivityStatusEnum_Draft, constdef.ActivityStatusEnum_Draft, as.UpdateActivity)
as.RegisterHandler(constdef.ActivityStatusEnum_Draft, constdef.ActivityStatusEnum_Testing, as.TestActivity)
as.RegisterHandler(constdef.ActivityStatusEnum_Testing, constdef.ActivityStatusEnum_Draft, as.UpdateActivity)
as.RegisterHandler(constdef.ActivityStatusEnum_Testing, constdef.ActivityStatusEnum_OnlineApproval, as.SubmitOnlineApproval)
as.RegisterHandler(constdef.ActivityStatusEnum_OnlineApproval, constdef.ActivityStatusEnum_Draft, as.OnlineApprovalReject)
as.RegisterHandler(constdef.ActivityStatusEnum_OnlineApproval, constdef.ActivityStatusEnum_Running, as.RunningActivity)
return as
}
// RegisterHandler 注册状态机
// before状态从DB获取,after状态由前端传入,省去了事件元素,让前端感知用户事件推到次态,然后直接将次态给后端
// 注:完整的状态机一般会包含如下四个元素 初态 事件 次态 动作,通过初态和事件决定次态以及要执行的动作,但我们这里是变形写法
// 初态后端自己查DB中的,次态前端给,然后根据 初态_次态 决定 动作
func (as *ActivityService) RegisterHandler(before, after constdef.ActivityStatusEnum, handler ActivityHandler) {
stateChange := fmt.Sprintf("%d_%d", before, after)
as.stateMachine[stateChange] = handler
}
// GetHandlerByState 根据状态机获取任务类型处理器
func (as *ActivityService) GetHandlerByState(before, after constdef.ActivityStatusEnum) (ActivityHandler, error) {
stateChange := fmt.Sprintf("%d_%d", before, after)
if as.stateMachine == nil {
return nil, errors.New("stateMachine not data_init")
}
handler, ok := as.stateMachine[stateChange]
if ok {
return handler, nil
}
return nil, errors.New(fmt.Sprintf("the update status is incorrect %s, the process does not exist", stateChange))
}
// SaveActivity 是一个通用的入口,里面包含了状态机,创建、更新、提测等改变活动元信息的都从此处进入,当然:审批除外,审批的也会改变活动元信息状态,但是是通过回调实现的
func (as *ActivityService) SaveActivity(ctx *gin.Context) {
// 从ctx获取参数afterActivityInfo、以及根据业务诉求做一些参数校验,这里都省去了
afterActivityInfo := &model.Activity{}
// 初态默认为空白,比如新建
beforeStatus := constdef.ActivityStatusEnum_Blank
afterStatus := constdef.ActivityStatusEnum(afterActivityInfo.Status)
beforeActivityInfo := &model.Activity{}
if afterActivityInfo.Id != 0 {
// 已有的活动,查询DB,获取DB中改活动的初态
res, err := dal.GetActivityById(afterActivityInfo.Id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
beforeStatus = constdef.ActivityStatusEnum(res.Status)
beforeActivityInfo = res
}
handler, err := as.GetHandlerByState(beforeStatus, afterStatus)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
如果不是新建则需要校验写权限
//if beforeStatus != constdef.ActivityStatusEnum_Blank {
//
// err = as.CheckTaskTypeWriterAuth(afterActivityInfo)
// if err != nil {
// return err
// }
//}
err = handler(beforeActivityInfo, afterActivityInfo)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
ctx.JSON(http.StatusOK, gin.H{
"message": "pong",
})
}
// CreateActivity 创建活动信息
func (as *ActivityService) CreateActivity(beforeActivityInfo, afterActivityInfo *model.Activity) error {
return nil
}
// UpdateActivity 编辑活动信息
func (as *ActivityService) UpdateActivity(beforeActivityInfo, afterActivityInfo *model.Activity) error {
return nil
}
// TestActivity 活动信息提测
func (as *ActivityService) TestActivity(beforeActivityInfo, afterActivityInfo *model.Activity) error {
return nil
}
// SubmitOnlineApproval 活动信息申请上线 ,一般应该发起流水线或者其他方式的审批
func (as *ActivityService) SubmitOnlineApproval(beforeActivityInfo, afterActivityInfo *model.Activity) error {
return nil
}
// OnlineApprovalReject 审批驳回,在回调方法OnlineApproval里面使用
func (as *ActivityService) OnlineApprovalReject(beforeActivityInfo, afterActivityInfo *model.Activity) error {
return nil
}
// RunningActivity 审批通过,在回调方法OnlineApproval里面使用
func (as *ActivityService) RunningActivity(beforeActivityInfo, afterActivityInfo *model.Activity) error {
return nil
}
// OnlineApproval 上线审批回调
func (as *ActivityService) OnlineApproval(ctx *gin.Context) {
// 实际业务代码beforeActivityInfo和afterActivityInfo应该从ctx的Param中取的
beforeActivityInfo := &model.Activity{}
afterActivityInfo := &model.Activity{}
approve := true // 审批结果,即是通过还是拒绝也应该从ctx的参数中取,这里写死为通过了
var err error
if approve {
err = as.RunningActivity(beforeActivityInfo, afterActivityInfo)
} else {
err = as.OnlineApprovalReject(beforeActivityInfo, afterActivityInfo)
}
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
ctx.JSON(http.StatusOK, gin.H{
"message": "pong",
})
}
// GetActivities 根据条件获取活动列表
func (as *ActivityService) GetActivities(ctx *gin.Context) {
// 实际业务代码param应该从ctx的Param中取的
param := dal.GetActivitiesParam{}
activities,total, err := dal.GetActivitiesByParam(param)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
ctx.JSON(http.StatusOK, gin.H{
"message": activities,
"total":total,
})
}
// GetActivityDetailById 根据活动ID获取活动详情
func (as *ActivityService) GetActivityDetailById(ctx *gin.Context) {
// 实际业务代码id应该从ctx的Param中取的
id := int64(1)
activity, err := dal.GetActivityById(id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
ctx.JSON(http.StatusOK, gin.H{
"message": activity,
})
}
路由我们也专门拆出了目录和文件管理,而不是写到main.go中,这在之前的一些博客中也都介绍过啦,如:59.Gin框架路由拆分与注册。
主要涵盖了以下路由,基本包含了对于一个活动元信息的状态机全流程
ID
查询详情路由,对应前端一个查看详情页面save_activity
路由,涵盖了前端的新建、编辑更新、提测、申请上线等请求activity_online_approval
路由则主要用于上线审批结果的回调,对应状态机中的申请上线中、运行中以及草稿三个状态的流转。package routers
import (
"github.com/gin-gonic/gin"
"golang-trick/37-load-local-cache/service"
"net/http"
)
func Init() *gin.Engine {
r := gin.Default()
// 获取活动元信息列表(可分页获取)
r.GET("/activity", service.NewActivityService().GetActivities)
// 根据活动ID获取指定活动详情
r.GET("/activity_detail/:id", service.NewActivityService().GetActivityDetailById)
// 新建、编辑更新、提测、申请上线等都请求的该路由,【状态机在该路由中】
r.POST("/save_activity", service.NewActivityService().SaveActivity)
// 上线审批结果,实际工作中,应该是流水线回调或者飞书、微信或钉钉等审批回调
r.POST("/activity_online_approval", service.NewActivityService().OnlineApproval)
// 下线审批结果,实际工作中,应该是流水线回调或者飞书、微信或钉钉等审批回调
r.POST("/activity_offline_approval", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
return r
}
main
文件中一般都会比较简洁,主要做资源加载,服务启动等工作。
这里就是做的DB
初始化、本地缓存加载、路由注册、服务启动等工作
package main
import (
"golang-trick/37-load-local-cache/cache"
"golang-trick/37-load-local-cache/dal"
"golang-trick/37-load-local-cache/routers"
)
func main() {
// 初始化DB
err := dal.InitDB()
if err != nil {
panic(err)
}
// 加载本地缓存
err = cache.LoadActivity()
if err != nil {
panic(err)
}
cache.RefreshCache()
// gin 路由注册
r := routers.Init()
// 服务启动
r.Run(":8080")
}