62.状态机实践(活动管理系统:二)

文章目录

  • 一、简介
  • 二、状态机实践(活动元信息管理)
    • 1、dal/db.go
    • 2、dal/activity.go
    • 3、constdef/activity.go
    • 4、service/activity.go
    • 5、routes/routes.go
    • 6、main.go

代码地址:https://gitee.com/lymgoforIT/golang-trick/tree/master/37-load-local-cache

一、简介

在上一节61.本地缓存加载与使用实践(活动管理系统:一)中,我们以活动元信息为例介绍了本地缓存的加载实践,本节想介绍一下另一个非常常见的编码技巧实践:状态机

之前本人已经有好几篇文章都介绍过状态机啦,但都有点单一,所以这次想举个具体的例子,结合Gin搞个实践示例,当然,主要还是介绍编码套路,一些简单的CRUD函数不会写的很详细

之前的文章地址如下:
22.有限状态机(一)go语言fsm库
23.有限状态机(二)状态模式实现
24.有限状态机(三)表驱动法Go实现

二、状态机实践(活动元信息管理)

首先看下状态机要实现的效果
62.状态机实践(活动管理系统:二)_第1张图片

  • 其中方框表示状态,箭头表示事件和状态流转
  • 共有空白、草稿、测试中、上线审核中、运行中、下线审核中、已失效七种状态
  • 绿色的为最基本的,也是本次要做的,蓝色框出的不一定需要,所以本次为了简洁就没有处理,即使要处理,理解了本文介绍的基本模型,新增状态是很简单的

实现后代码目录结构如下
62.状态机实践(活动管理系统:二)_第2张图片

1、dal/db.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
}

2、dal/activity.go

Activity model相关的与DB交互的函数都写到该文件中,代码非常简单,需要重点强调的是GetActivitiesByParam函数,这也是工作中非常常用的一个编码技巧,对应下面小结中的第2点。这里小结一下和DB交互的几种常见方式吧!

  1. 根据指定字段或者ID获取记录,常用于脚本或一些单一的查询。
  2. 定义和model字段很像的结构体(常外加分页相关字段),根据结构体中各字段的有无拼SQL,常用于元信息管理系统,有多个搜索框的页面。
  3. 函数式选项模式,常用于需要非常灵活的拼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
}

3、constdef/activity.go

对于常量,我们一般习惯专门定义到相应的常量文件中,或者定义到要使用该常量的文件开头,这里选用了前者的方式,单独定义到文件中。

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 // 测试中
)

4、service/activity.go

在介绍路由前,首先介绍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 {
	// 可能还有很多校验逻辑与业务逻辑,最后调用 dal.Save(afterActivityInfo)创建记录
	// 注:gorm中Save会先通过主键find,如果有返回结果就是update,否则就是create
	return nil
}

// UpdateActivity 编辑活动信息
func (as *ActivityService) UpdateActivity(beforeActivityInfo, afterActivityInfo *model.Activity) error {
	// 可能还有很多校验逻辑与业务逻辑,最后调用 dal.Save(afterActivityInfo)创建记录
	// 注:gorm中Save会先通过主键find,如果有返回结果就是update,否则就是create
	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,
	})
}

5、routes/routes.go

路由我们也专门拆出了目录和文件管理,而不是写到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
}

6、main.go

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")
}

你可能感兴趣的:(go,gin,golang,状态机)