herman

command module
v1.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2023 License: MIT Imports: 1 Imported by: 0

README

Herman框架

1. 序言

介绍

Herman基于Gin,Casbin,Kafka,Mysql,Redis,Zap,Cobra,Grom开发,专注于后端快速上手的一款开源,简洁,轻量框架。

项目结构
├─application ------------------------------------------------- 应用程序目录
│  ├─constants ------------------------------------------------ 常量存放目录
│  ├─controllers ---------------------------------------------- 控制器目录
│  ├─models --------------------------------------------------- 数据模型目录
│  ├─repositories --------------------------------------------- 仓储层目录
│  ├─services ------------------------------------------------- 服务处理目录
│  ├─validates ------------------------------------------------ 验证器目录
│  ├─request.go ----------------------------------------------- 请求对象库
│  └─response.go ---------------------------------------------- 响应对象库
├─cmd --------------------------------------------------------- 命令管理目录
├─config ------------------------------------------------------ 配置文件目录
├─database ---------------------------------------------------- 数据库相关目录
│  ├─migrations ----------------------------------------------- 数据迁移目录
│  └─seeders -------------------------------------------------- 数据填充目录
├─jobs -------------------------------------------------------- 队列作业目录
├─kernel ------------------------------------------------------ 框架核心目录
│  ├─app ------------------------------------------------------ 程序容器目录
│  ├─casbin --------------------------------------------------- Casbin核心目录
│  ├─cobra ---------------------------------------------------- 命令核心目录
│  ├─core ----------------------------------------------------- 自定义核心类目录
│  ├─kafka ---------------------------------------------------- kafka核心目录
│  ├─log ------------------------------------------------------ 日志核心目录
│  ├─mysql ---------------------------------------------------- Mysql核心目录
│  ├─redis ---------------------------------------------------- Redis核心目录
│  └─servers -------------------------------------------------- 程序启动管理目录
├─runtime ----------------------------------------------------- 运行目录
│  └─logs ----------------------------------------------------- 日志记录目录
├─storages ---------------------------------------------------- 存储目录
├─tests ------------------------------------------------------- 单元测试目录
├─.air.toml --------------------------------------------------- Air热重载配置文件
├─.gitignore -------------------------------------------------- gitignore文件
├─go.mod ------------------------------------------------------ go.mod文件
├─go.sum ------------------------------------------------------ go.sum文件
├─config.yaml.debug ------------------------------------------- 开发环境配置文件
├─config.yaml.test -------------------------------------------- 测试环境配置文件
├─config.yaml.release ----------------------------------------- 正式环境配置文件
├─Dockerfile -------------------------------------------------- Dodcker镜像配置
├─docker-compose.yaml ----------------------------------------- Dodcker容器编排文件
├─LICENSE ----------------------------------------------------- 许可证文件
├─Makefile ---------------------------------------------------- Makefile文件
├─main.go ----------------------------------------------------- 入口文件
└─README.md --------------------------------------------------- Readme文件
开发规范
(1)目录与文件命名
  • 目录名称采用小驼峰命名(首字母小写)
  • .go文件采用下划线命名,例如:useruser_login
  • 数据库迁移文件采用下划线命名,例如:1_init.down.sql1_init.up.sql,1为版本号,init为自定义名称,down代表回滚,up代表迁移。
  • 资源文件(图片,CSS文件,JS文件等)均采用蛇形命名,例如CSS文件:test.csstest_user.css,以此类推。
(2)函数、方法、结构体
  • 函数和方法命名可以大驼峰(首字母大写)和小驼峰(首字母小写)命名,具体看业务需求,如果只需在本包调用则小驼峰即可,否则需要大驼峰

  • 结构体名称、字段名、json标签一律使用大驼峰命名,示例:

    type Users struct {
       Id           uint       `json:"id" gorm:"primary_key"`
       User         string     `json:"user"`
       Password     string     `json:"password"`
       Nickname     string     `json:"nickname"`
       Sex          string     `json:"sex"`
       Age          int        `json:"age"`
       Region       string     `json:"region"`
       Phone        string     `json:"phone"`
       Email        string     `json:"email"`
       Introduction string     `json:"introduction"`
       Status       string     `json:"status"`
       CreatedAt    time.Time  `json:"createdAt"`
       UpdatedAt    time.Time  `json:"updatedAt"`
       DeletedAt    *time.Time `json:"deletedAt" sql:"index"`
    }
    
(3)变量与常量
  • 全局变量和函数方法规范相似,如果需要跨包调用,则采用大驼峰(首字母大写),否则采用小驼峰(首字母小写)
  • 常量使用大驼峰命名(首字母大写),例如:SuccessTokenNotExit
(4)数据库
  • 数据表名没有前缀,表名不能出现大写字母,建议以蛇形定义,例如:useruser_role
  • 字段名称采用蛇形命名,不能出现大写字母,例如:user_iduser_name
  • 推荐主键索引名为 pk_字段名,唯一索引名为 uk_字段名,普通索引名则为 idx_字段名,例如:pk_user_iduk_user_nameidx_user_age
  • 主键和外键创建则推荐pk+表名(主键),如:pk_main,fk+从表+主表名(外键),如:fk_sub_main
安装
(1)修改环境文件

为了项目开发管理灵活性,根目录分别有3个环境文件,分别为config.yaml.debug开发环境文件,config.yaml.test测试环境文件,config.yaml.release正式环境文件,如果当前使用的环境为开发环境,则修改config.yaml.debugconfig.yaml,例如:

cp config.yaml.debug config.yaml

以此类推。

(2)配置MySQL和Redis

项目启动依赖于Mysql和Redis,所以在启动之前,必须配置好MySQL和Redis的服务连接参数,否则程序无法启动。

# 数据库配置
mysql:
  # 连接IP地址
  host: 127.0.0.1
  # 连接端口号
  port: 3306
  # 连接用户名
  user: root
  # 连接密码
  password: root
  # 连接数据库名称
  dbname: herman
  # 最大连接数
  max_open_conn: 100
  # 最大连接空闲数,建议和max_open_conn一致
  max_idle_conn: 10

# Redis配置
redis:
  # 连接IP地址
  host: 127.0.0.1
  # 连接端口号
  port: 6380
  # 连接用户名
  username:
  # 连接密码
  password:
  # 默认数据库,默认是0
  db: 0
  # 最大连接数
  pool_size: 100
(3)安装依赖

执行该命令之前,Go环境必须已经安装。

go mod download
(4)程序启动

(1)编译后启动(推荐正式环境使用)

go build -o herman . # 项目编译成二进制文件herman
herman server --host=0.0.0.0 --port=8000 --migrate=true # host和port是可选的,但是migtate首次运行程序是必须的,会自动帮你迁移数据表到数据库

(2)非编译启动

go run main.go server --host=0.0.0.0 --port=8000 --migrate=true # 首次非编译启动程序,host和port也是可选的

(3)热重载启动(推荐开发环境使用)

该启动方式运行之前,必须要完成数据库迁移工作,否则会发生致命错误。使用该功能要求Go 的版本为 1.16及以上。

go install github.com/cosmtrek/air@latest # 如果已经安装则无需操作此步

您可以执行以下命令初始化,把默认配置添加到当前路径下的.air.toml 文件。

air init

配置示例:

# [Air](https://github.com/cosmtrek/air) TOML 格式的配置文件

# 工作目录
# 使用 . 或绝对路径,请注意 `tmp_dir` 目录必须在 `root` 目录下
root = "."
tmp_dir = "tmp"

[build]
# 只需要写你平常编译使用的shell命令。你也可以使用 `make`
cmd = "go build -o ./tmp/herman.exe ."
# 由`cmd`命令得到的二进制文件名
bin = "tmp\\herman.exe server"
# 监听以下文件扩展名的文件.
include_ext = ["go", "tpl", "tmpl", "html"]
# 忽略这些文件扩展名或目录
exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules"]
# 监听以下指定目录的文件
include_dir = []
# 排除以下文件
exclude_file = []
# 如果文件更改过于频繁,则没有必要在每次更改时都触发构建。可以设置触发构建的延迟时间
delay = 1000 # ms
# 发生构建错误时,停止运行旧的二进制文件。
stop_on_error = true
# air的日志文件名,该日志文件放置在你的`tmp_dir`中
log = "air_errors.log"

[log]
# 显示日志时间
time = true

[color]
# 自定义每个部分显示的颜色。如果找不到颜色,使用原始的应用程序日志。
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"

[misc]
# 退出时删除tmp目录
clean_on_exit = true

热重载启动:

air

2. 架构

生命周期

使用一门技术,了解它的生命周期是必不可少,只有你去了解它,使用起来才会更加自信。以下是Herman处理一个HTTP请求的流程:

  • 应用入口:Golang大部分项目都是main.go文件入口,Herman也不例外,在没有编译成二进制的前提下,入口从main.go文件开始。
  • 服务注册:进入cobra的init函数进行配置,日志初始化;框架版本,服务,数据库迁移,JWT令牌注册。
  • 设置运行模式:采用Gin框架设置当前的运行模式。
  • Gin框架启动:注册核心中间件,初始化路由,监听HTTP请求。
  • 控制器:接收请求上下文,处理请求参数,验证器和服务调用,以及响应返回。
  • 验证器:接收控制器处理好的参数进行验证,验证通过的参数返回给验证器。
  • 服务层:接收验证器验证通过的参数,调用仓储层获取数据库数据,然后把数据返回给控制器。
  • 仓储层:这一层又被称为数据库模型与数据交互的桥梁,主要操作数据库模型,再次封装增删改查,实现代码高度解耦。
  • 模型层:与数据库数据表一对一绑定,数据表字段与模型结构体绑定。
  • 请求响应:所有逻辑处理完成,数据由控制器响应返回。
容器

Golang虽然是一门面向过程的语言,但是Herman也引入了容器的概念,对项目核心的对象,比如Redis,MySQL,Casbin等都存放在/kernel/core/Container.go文件中。

package core

import (
	"github.com/casbin/casbin/v2"
	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

var (
	Engine *gin.Engine
	Log    *zap.SugaredLogger
	Db     *gorm.DB
	Redis  *redis.Client
	Casbin *casbin.CachedEnforcer
)
中间件

中间件分为前置中间件后置中间件的,主要存放在/middlewares,比如以下定义的中间件:

// ServerHandler 服务管理中间件
// @return gin.HandlerFunc
func ServerHandler() gin.HandlerFunc {
	return func(ctx *gin.Context) {
		Reload() // 请求前执行
		ctx.Next()
		Close() // 请求后执行
	}
}

中间件定义调用均采用Gin框架提供的API,所以调用中间可以在路由,也可以在别处,具体看业务要求,以下在程序启动后调用:

func NewServer(host string, port uint) {
	// 设置gin框架运行模式
	gin.SetMode(app.Config.Mode)
	// 启动gin框架
	engine := gin.New()
	// 注册中间件
	engine.Use(log.GinLogger()).Use(middlewares.CatchError()).Use(middlewares.ServerHandler())
	// 初始化路由
	core.Engine = routers.InitRouter(engine)
	// 启动服务
	Run(host, port)
}

你也可以在路由中调用,比如鉴权中间件:

// Jwt 鉴权
// @return gin.HandlerFunc 返回一个中间件上下文
func Jwt(guard string) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		if VerifyRoute(ctx.Request.URL.Path, ctx.Request.Method, MiddlewareConstant.ExcludeRoute) {
			return
		}
		claims := utils.JwtVerify(ctx, guard)
		switch guard {
		case "user", "mobile": // 前台和移动端(用户)
			// 用户信息存储在请求中
			ctx.Set("user", repositories.User().GetUserInfo(claims.Uid))
		case "admin": // 管理员后台
			ctx.Set("admin", repositories.Admin().GetAdminInfo(claims.Uid))
		case "merchant": // 商家后台

		default:
			panic(MiddlewareConstant.GuardError)
		}
		ctx.Next()
	}
}

路由定义中调用:

// 后台模块
adminRouter := api.Group("/admin", middlewares.Jwt("admin"), middlewares.CheckPermission())
{
    admin.Router(adminRouter)
}
命令行

命令行核心采用cobra实现,主要存放在cmd目录,命令注册在/kernel/cobra/cobra.go文件,比如以下例子:

// HermanVersionCmd 获取herman版本号
var (
	HermanVersionCmd = &cobra.Command{
		Use:          "version",
		Short:        "Get herman version",
		Example:      "herman version",
		SilenceUsage: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			fmt.Printf(`Herman version: %v`, color.GreenString(app.Version))
			return nil
		},
	}
)

编写好之后,进行注册:

// rootCmd 定义命令行
var rootCmd = &cobra.Command{Use: "herman"}

// 注册命令行
func init() {
	// 执行命令前初始化操作
	cobra.OnInitialize(app.InitConfig, servers.ZapLogs, func() {
		if command.IsMigrate {
			// 数据库迁移
			_ = command.Migrate("up")
		}
		// 如果执行的是数据库迁移命令,则不需要加载初始化操作
		if !command.MigrationStatus {
			middlewares.Reload()
		}
	})

	// 注册框架版本命令
	rootCmd.AddCommand(command.HermanVersionCmd)
}

官方已经内置了几个命令:

(1)查看框架版本号

herman version # Herman version: 1.0.0

(2)数据库迁移

herman migrate --direction=up --number=1 # 表示迁移1个版本

这里每个参数需要绑定

// init 命令参数绑定
// @return void
func init() {
	// 迁移状态
	MigrationCmd.Flags().BoolVarP(&MigrationStatus, "status", "s", false, "Database migration status")
	// 迁移方式,up和down
	MigrationCmd.Flags().StringVarP(&direction, "direction", "d", "up", "Database migration")
	// 执行指定数据库版本,主要在出现Error: Dirty database version XX.使用
	MigrationCmd.Flags().UintVarP(&version, "version", "v", 0, "Database version")
	// 执行迁移的版本次数,比如回滚1个版本,可以执行herman -d down -n 1,不指定则全部迁移
	MigrationCmd.Flags().UintVarP(&number, "number", "n", 0, "Database migration steps")
}

命令绑定之后,可以随意结合,都是可选的,根据业务需求执行。

(3)随机生成JWT令牌

herman jwt:secret

(4)框架服务启动

herman server --host=0.0.0.0 --port=8000 --migrate=true # 启动服务并做数据库迁移

如果框架已经迁移过数据库,也可以这样启动服务:

herman server # 默认端口为8000

cobra扩展文档:https://cobra.dev/

队列

队列采用kafka,主要存放在jobs目录,比如以下短信发送例子:

// SendSms 发送短信队列
// @param string topic 消息主题
// @return void
func SendSms(topic string) {
	var data map[string]interface{}
	// 调用消费者对数据进行消费,并返回结构体
	kafkaConsumer := ExecConsumer(topic)
	for {
		// 从通道取出消费的数据
		message := <-kafkaConsumer.MessageQueue
		// 将取出的JSON数据转为map
		if err := json.Unmarshal(message, &data); err != nil {
			app.Log.Errorf("Consumer sms json data failed, err:%v", err)
		}
		execSend(data)
	}
}

服务层调用:

jobs.Dispatch(data,jobs.SendSms)

其中,以下代码为固定写法,目的是取出消费的数据:

	for {
		// 从通道取出消费的数据
		message := <-kafkaConsumer.MessageQueue
		// 将取出的JSON数据转为map
		if err := json.Unmarshal(message, &data); err != nil {
			app.Log.Errorf("Consumer sms json data failed, err:%v", err)
		}
		execSend(data)
	}

如果是延时队列则在Dispatch方法data参数中加入time参数即可,延时队列会根据time时间执行,如:

var data map[string]interface{}
data["topic"] = "sms_send"
data["time"] = time.Now().Add(time.Second * 60) // 设置时间戳为60秒之后
jobs.Dispatch(data,jobs.SendSms)
缓存

目前框架只支持Redis缓存,对象挂载在/kernel/core/container.go中,使用前要先设置上下文:

// 设置上下文
ctx := context.Background()

设置一个key值

val, err := core.Redis.Set(ctx, "key", 1)
fmt.Println(val)

取出一个key值

get := core.Redis.Get(ctx, "key").Result()
fmt.Println(get.Val(), get.Err())

设置一个key值并设置过期时间

core.Redis.Set(ctx, "key", 1, time.Minute*30)

更多Redis扩展:https://redis.uptrace.dev/zh/guide/

日志

日志集成Zap,这是一个强大的日志库,它在Herman中起到很关键的作用。

// 记录一个日志
app.Log.info(data)
// 记录一个日志并换行
app.Log.infoln(data)
// 调式
app.Log.Debug(data)
// 记录一个错误
app.Log.Error(data)
// 记录一个错误并换行
app.Log.Errorln(data)
// 记录一个错误并终止进程
app.Log.Fatal(data)

更多API文档:https://pkg.go.dev/go.uber.org/zap

辅助函数

辅助函数又称工具类,主要存放在/app/utils中,如果项目中存在一些碎片化的代码,想把它做进一步封装,就可以在该目录下创建一个工具文件,在里面完成相应的封装。比如我下方做了一个验证码工厂:

// Factory 初始化滑块验证码
// @return factory 返回一个验证码工厂
func Factory() (factory *CaptchaService.CaptchaServiceFactory) { // 行为校验配置模块(具体参数可从业务系统配置文件自定义)
	// 行为校验初始化
	factory = CaptchaService.NewCaptchaServiceFactory(
		CaptchaConfig.BuildConfig(app.Config.Captcha.CacheType,
			app.Config.Captcha.ResourcePath,
			&CaptchaConfig.WatermarkConfig{
				Text: app.Config.Captcha.Text,
			},
			nil, nil, app.Config.Captcha.CacheExpireSec))
	// 注册内存缓存
	factory.RegisterCache(Constant.MemCacheKey, CaptchaService.NewMemCacheService(CaptchaConstant.CacheMaxNumber))
	// 注册自定义配置redis数据库
	factory.RegisterCache(Constant.RedisCacheKey, CaptchaService.NewConfigRedisCacheService([]string{fmt.Sprintf("%s:%d",
		app.Config.Redis.Host,
		app.Config.Redis.Port,
	)},
		app.Config.Redis.UserName,
		app.Config.Redis.Password,
		false,
		app.Config.Redis.Db,
	))
	// 注册文字点选验证码服务
	factory.RegisterService(Constant.ClickWordCaptcha, CaptchaService.NewClickWordCaptchaService(factory))
	// 注册滑动拼图验证码服务
	factory.RegisterService(Constant.BlockPuzzleCaptcha, CaptchaService.NewBlockPuzzleCaptchaService(factory))

	return factory
}

封装好之后,在框架那个地方都可以调用,非常方便。

权限模型

Casbin是一种轻量级的开源访问控制框架,支持多种访问控制模型,如RBAC, ABAC和ACL。框架中已经采用了RBAC,适配GORM来做角色资源管理,可以灵活管理角色的权限。核心封装代码在/kernel/casbin/casbin.go。框架Casbin的对象挂载在容器/kernel/core/container.go,调用:

success, _ := core.Casbin.Enforce(info.User, ctx.Request.URL.Path, ctx.Request.Method)

更多学习:https://casbin.org/zh/docs/category/the-basics

配置

框架的所有配置都是通过读取根目录下的config.yaml文件所得,并且存放在config目录中,调用方式:

app.Config

比如获取MySQL的配置

app.Config.Mysql

当然,如果你不想创建配置文件作映射,也可以直接获取环境文件config.yaml的配置,但是不建议这么操作。

viper.Get("app")

3. 路由

路由沿用了Gin集成的功能,所有路由定义都在/routers/router.go,例子:

func InitRouter(rootEngine *gin.Engine) *gin.Engine {
	// 测试路由
	rootEngine.GET("/", func(context *gin.Context) {
		response := app.Request{Context: context}
		response.Success(app.D(map[string]interface{}{
			"message": "Welcome to Herman!",
		}))
	})
	// 设置路由前缀
	api := rootEngine.Group(app.Config.AppPrefix)
	// 获取验证码
	api.GET("/captcha", CaptchaController.GetCaptcha)
	// 检查验证码正确性
	api.POST("/captcha/check", CaptchaController.CheckCaptcha)

	// 用户模块
	userRouter := api.Group("/user", middlewares.Jwt("user"))
	{
		mobile.Router(userRouter)
	}

	// 后台模块
	adminRouter := api.Group("/admin", middlewares.Jwt("admin"), middlewares.CheckPermission())
	{
		admin.Router(adminRouter)
	}

	return rootEngine
}

4. 控制器

控制器层面的责任非常明确,只负责接收上下文获取参数调用以及响应,不做其他任何操作。调用这里包括调用验证器验证参数,调用服务层处理逻辑,然后响应返回。

// AddAdmin 管理员添加
// @param *gin.Context ctx 上下文
// @return void
func AddAdmin(ctx *gin.Context) {
	context := app.Request{Context: ctx} // 上下文二次封装
	data := context.Params() // 获取参数
	AdminService.Add(AdminValidate.Add.Check(data)) // 调用验证器验证参数,然后调用服务层处理逻辑
	context.Json(nil) // 响应返回
}

5. 验证器

验证器定义:

// Add 重写验证器结构体,切记不使用引用,而是拷贝
var Add = validates.Validates{Validate: AddValidate{}}

// AddValidate 管理员添加验证规则
type AddValidate struct {
	User         string       `json:"user" validate:"required,min=5,max=15" label:"用户名"`
	Password     string       `json:"password" validate:"required,min=6,max=15" label:"密码"`
	Roles        []role.Roles `json:"roles" validate:"required" label:"选择角色"`
	Photo        string       `json:"photo" validate:"omitempty,url,max=255" label:"头像"`
	Name         string       `json:"name" validate:"omitempty,max=20" label:"真实姓名"`
	Card         string       `json:"card" validate:"omitempty,max=20" label:"身份证号码"`
	Sex          uint8        `json:"sex" validate:"required,oneof=1 2 3" label:"性别"`
	Age          uint8        `json:"age" validate:"required,min=0,max=120" label:"年龄"`
	Region       string       `json:"region" validate:"omitempty,max=255" label:"住址"`
	Phone        string       `json:"phone" validate:"omitempty,len=11" label:"手机号码"`
	Email        string       `json:"email" validate:"omitempty,email" label:"邮箱"`
	Introduction string       `json:"introduction" validate:"omitempty" label:"简介"`
	State        uint8        `json:"state" validate:"required,oneof=1 2" label:"状态"`
	Sort         uint         `json:"sort" validate:"omitempty" label:"排序"`
}

如果有对验证器公共结构体进行重写,那么就可以使用结构体的公共方法check,上面控制器的例子就是使用了验证器的公共方法。

// Check 验证方法
// @param map[string]interface{} data 待验证数据
// @return void
func (base Validates) Check(data map[string]interface{}) (toMap map[string]interface{}) {
	// map赋值给结构体
	if err := mapstructure.WeakDecode(data, &base.Validate); err != nil {
		panic(constants.MapToStruct)
	}
	if err := Validate(base.Validate); err != nil {
		panic(err.Error())
	}

	toMap, err := utils.ToMap(base.Validate, "json")

	if err != nil {
		panic(constants.StructToMap)
	}
	return toMap
}

如果没有额外的业务扩展,这样是非常便利了,只关注验证规则如何去定义就可以。当然,如果你需要做一些验证扩展也是可以的,比如管理员登录:

  • 控制器
// Login 管理员登录
// @param *gin.Context ctx 上下文
// @return void
func Login(ctx *gin.Context) {
	context := app.Request{Context: ctx}
	data := context.Params()
	context.Json(AdminService.Login(AdminValidate.Login(data)), AdminConstant.LoginSuccess)
}
  • 验证器

// CaptchaLoginValidate 管理员登录验证结构体
type CaptchaLoginValidate struct {
	User        string `json:"user" validate:"required,min=5,max=15" label:"用户名"`
	Password    string `json:"password" validate:"required,min=6,max=15" label:"密码"`
	CaptchaType int    `json:"captchaType" validate:"required,numeric,oneof=1 2" label:"验证码类型"`
	Token       string `json:"token" validate:"required" label:"验证码Token"`
	PointJson   string `json:"pointJson" validate:"required" label:"验证码PointJson"`
}

// ExcludeCaptchaLoginValidate 管理员登录排除验证码相关验证结构体
type ExcludeCaptchaLoginValidate struct {
	User     string `json:"user" validate:"required,min=5,max=15" label:"用户名"`
	Password string `json:"password" validate:"required,min=6,max=15" label:"密码"`
}

// Login 登录验证器
// @param map[string]interface{} data 待验证数据
// @return toMap 返回验证通过的数据
func Login(data map[string]interface{}) (toMap map[string]interface{}) {
	// 判断是否需要验证码
	if !app.Config.Captcha.Switch {
		return excludeCaptchaLogin(data)
	}
	return captchaLogin(data)
}

// captchaLogin 验证码登录验证器
// @param map[string]interface{} data 待验证数据
// @return toMap 返回验证通过的数据
func captchaLogin(data map[string]interface{}) (toMap map[string]interface{}) {
	var login CaptchaLoginValidate
	// map赋值给结构体
	if err := mapstructure.WeakDecode(data, &login); err != nil {
		panic(constants.MapToStruct)
	}

	if err := validates.Validate(login); err != nil {
		panic(err.Error())
	}

	// 验证码二次验证
	err := utils.Factory().GetService(fmt.Sprintf("%s", data["captchaType"])).Verification(fmt.Sprintf("%s", data["token"]),
		fmt.Sprintf("%s", data["PointJson"]))
	if err != nil {
		panic(CaptchaConstant.CheckCaptchaError)
	}

	toMap, err = utils.ToMap(&login, "json")
	if err != nil {
		panic(constants.StructToMap)
	}

	return toMap
}

// excludeCaptchaLogin 排除验证码登录验证器
// @param map[string]interface{} data 待验证数据
// @return toMap 返回验证通过的数据
func excludeCaptchaLogin(data map[string]interface{}) (toMap map[string]interface{}) {
	var login ExcludeCaptchaLoginValidate
	// map赋值给结构体
	if err := mapstructure.WeakDecode(data, &login); err != nil {
		panic(constants.MapToStruct)
	}

	if err := validates.Validate(login); err != nil {
		panic(err.Error())
	}

	toMap, err := utils.ToMap(&login, "json")
	if err != nil {
		panic(constants.StructToMap)
	}

	return toMap
}

业务需要扩展验证器,可以直接在验证器文件中自定义规则即可,比如上面的例子就是把管理员登录是否需要验证码做了2种场景验证。

6. 服务

服务层主要责任是逻辑处理,服务层没有什么约束,可以调用仓储层,工具类等等,但是这里值得注意的是,如果需要开启数据库事务的,必须要在这一层开启,然后在事务中进行多维度调用。例子如下:

err := core.Db().Transaction(func(tx *gorm.DB) error {
   // casbin重新初始化
   _, _ = casbin.InitEnforcer(casbin.GetAdminPolicy(), tx)
   // 判断角色Key是否存在
   if isExist, _ := repositories.Role(tx).KeyIsExist(data["role"].(string)); isExist {
      return errors.New(RoleConstant.KeyExist)
   }
   roles := data["roles"]
   rules := data["rules"]
   delete(data, "roles")
   delete(data, "rules")
   // 添加角色信息
   roleInfo, err := repositories.Role(tx).Insert(data)
   if err != nil {
      return errors.New(RoleConstant.AddFail)
   }
   // 添加策略
   if err := AddPolicies(roles.([]role.Roles), rules.([]role.Rules), roleInfo); err != nil {
      return err
   }
   return nil
})

如果有使用到Casbin,那么Casbin的Db也需要更新,比如上面代码的:

_, _ = casbin.InitEnforcer(casbin.GetAdminPolicy(), tx)

如果遇到错误,必须要把错误返回,否则事务不会进行回滚,比如上面代码:

roleInfo, err := repositories.Role(tx).Insert(data)
if err != nil {
    return errors.New(RoleConstant.AddFail)
}

有错误返回,数据库会进行回滚。

7. 仓储

仓储层是位于Service层和Model层之间,是对Model层进一步封装。仓储层公共方法已有新增更新删除根据查询条件获取详情查询数据是否存在获取列表数据获取全部数据。代码如下:

package repositories

import (
	"github.com/herman-hang/herman/app/constants"
	"github.com/herman-hang/herman/app/utils"
	"github.com/mitchellh/mapstructure"
	"gorm.io/gorm"
)

// BaseRepository 公共仓储层
type BaseRepository struct {
	Model interface{}
	Db    *gorm.DB
}

// PageInfo 分页结构体
type PageInfo struct {
	Page     int64  `json:"page"`     // 页码
	PageSize int64  `json:"pageSize"` // 每页大小
	Keywords string `json:"keywords"` // 关键字
}

// Insert 新增
// @param map[string]interface{} data 待添加数据
// @return toMap err 查询数据,错误信息
func (base *BaseRepository) Insert(data map[string]interface{}) (toMap map[string]interface{}, err error) {
	// 初始化ID,让ID持续自增
	data["id"] = constants.InitId
	if err := mapstructure.WeakDecode(data, base.Model); err != nil {
		return nil, err
	}
	if err := base.Db.Create(base.Model).Error; err != nil {
		return nil, err
	}
	// 模型拷贝
	tempStruct := base.Model
	toMap, err = utils.ToMap(tempStruct, "json")
	if err != nil {
		return nil, err
	}
	return toMap, nil
}

// Find 根据查询条件获取详情
// @param map[string]interface{} condition 查询条件
// @param []string fields 查询指定字段
// @return data err 详情数据,错误信息
func (base *BaseRepository) Find(condition map[string]interface{}, fields ...[]string) (info map[string]interface{}, err error) {
	data := make(map[string]interface{})
	info = make(map[string]interface{})
	if len(fields) > 0 {
		if err := base.Db.Model(&base.Model).Where(condition).Select(fields[0]).Find(&data).Error; err != nil {
			return nil, err
		}
	} else {
		if err := base.Db.Model(&base.Model).Where(condition).Find(&data).Error; err != nil {
			return nil, err
		}
	}
	if len(data) > 0 {
		for k, v := range data {
			// 下划线转为小驼峰
			info[utils.UnderscoreToLowerCamelCase(k)] = v
		}
	}
	return info, nil
}

// Update 更新
// @param []uint ids 查询条件
// @param map[string]interface{} attributes 待更新数据
// @return error 错误信息
func (base *BaseRepository) Update(ids []uint, data map[string]interface{}) error {
	var attributes = make(map[string]interface{})
	// 驼峰转下划线
	for k, v := range data {
		k := utils.ToSnakeCase(k)
		attributes[k] = v
	}
	if err := base.Db.Model(&base.Model).Where("id IN (?)", ids).Updates(attributes).Error; err != nil {
		return err
	}
	return nil
}

// Delete 删除
// @param []uint ids 主键ID
// @return error 错误信息
func (base *BaseRepository) Delete(ids []uint) error {
	if err := base.Db.Delete(&base.Model, ids).Error; err != nil {
		return err
	}
	return nil
}

// IsExist 查询数据是否存在
// @param map[string]interface{} condition 查询条件
// @return bool 返回一个bool值
func (base *BaseRepository) IsExist(condition map[string]interface{}) bool {
	data := make(map[string]interface{})
	err := base.Db.Model(&base.Model).Where(condition).Find(&data).Error
	if err != nil && len(data) > constants.LengthByZero {
		return true
	}
	return false
}

// GetList 获取列表数据
// @param string query 查询条件
// @param []string fields 查询指定字段
// @param string order 排序条件
// @param map[string]interface{} pageInfo 列表分页和关键词数据
// @return list total pageNum err 返回列表,总条数,总页码数,错误信息
func (base *BaseRepository) GetList(query string, fields []string, order string, pageInfo ...map[string]interface{}) (data map[string]interface{}, err error) {
	var (
		page    PageInfo
		total   int64
		pageNum int64
		list    []map[string]interface{}
	)
	if len(pageInfo) > 0 {
		if err := mapstructure.WeakDecode(pageInfo[0], &page); err != nil {
			panic(constants.MapToStruct)
		}
	}
	// 总条数
	base.Db.Model(&base.Model).Count(&total)
	// 计算总页数
	if page.PageSize != 0 && total%page.PageSize != 0 {
		pageNum = total / page.PageSize
		pageNum++
	}
	// 示例 query = fmt.Sprintf(" dns like '%%%s' ", createDbnameInfo.DNS)
	err = base.Db.Model(&base.Model).
		Select(fields).
		Where(query).
		Order(order).
		Limit(int(page.PageSize)).
		Offset(int((page.Page - 1) * page.PageSize)).
		Find(&list).Error
	if err != nil {
		return nil, err
	}
	data = map[string]interface{}{
		"list":     list,          // 数据
		"total":    total,         // 总条数
		"pageNum":  pageNum,       // 总页数
		"pageSize": page.PageSize, // 每页大小
		"page":     page.Page,     // 当前页码
	}
	return data, nil
}

// GetAllData 获取全部数据
// @param []string fields 查询指定字段
// @return list err 返回列表,错误信息
func (base *BaseRepository) GetAllData(fields []string) (data []map[string]interface{}, err error) {
	if len(fields) > 0 {
		if err := base.Db.Model(&base.Model).Select(fields).Find(&data).Error; err != nil {
			return nil, err
		}
	} else {
		if err := base.Db.Model(&base.Model).Find(&data).Error; err != nil {
			return nil, err
		}
	}
	return data, nil
}

在仓储层中要使用以上方法,你需要根据Model创建对应的子仓储,然后继承公共的BaseRepository结构体才能使用,比如一下是管理员的仓储层。

package repositories

import (
	AdminConstant "github.com/herman-hang/herman/app/constants/admin"
	"github.com/herman-hang/herman/app/models"
	"github.com/herman-hang/herman/kernel/core"
	"gorm.io/gorm"
)

// AdminRepository 管理员表仓储层
type AdminRepository struct {
	BaseRepository
}

// Admin 实例化管理员表仓储层
// @param *gorm.DB tx 事务
// @return AdminRepository 返回管理员表仓储层
func Admin(tx ...*gorm.DB) *AdminRepository {
	if len(tx) > 0 && tx[0] != nil {
		return &AdminRepository{BaseRepository{Model: new(models.Admin), Db: tx[0]}}
	}

	return &AdminRepository{BaseRepository{Model: new(models.Admin), Db: core.Db}}
}

// GetAdminInfo 获取管理员信息
// @param interface{} attributes 管理员id或者管理员user
// @return admin 返回当前管理员的信息
func (u AdminRepository) GetAdminInfo(attributes interface{}) (admin *models.Admin) {
	var err error
	switch attributes.(type) {
	case uint:
		err = core.Db.Where("id = ?", attributes).Find(&admin).Error
	case string:
		err = core.Db.Where("user = ?", attributes).Find(&admin).Error

	}
	if err != nil {
		panic(AdminConstant.GetAdminInfoFail)
	}

	return admin
}

重点在这里:

// AdminRepository 管理员表仓储层
type AdminRepository struct {
	BaseRepository
}

另外,每个子仓储都必须写上实例化方法:

// Admin 实例化管理员表仓储层
// @param *gorm.DB tx 事务
// @return AdminRepository 返回管理员表仓储层
func Admin(tx ...*gorm.DB) *AdminRepository {
	if len(tx) > 0 && tx[0] != nil {
		return &AdminRepository{BaseRepository{Model: new(models.Admin), Db: tx[0]}}
	}

	return &AdminRepository{BaseRepository{Model: new(models.Admin), Db: core.Db}}
}

根据不同的仓储层,实例化结构体要随之改变。比如上面的是管理员仓储层,那么实例化的就是管理员仓储层的结构体,完成了上面的操作之后,这些方法就能在Service层调用了,比如:

// 获取管理员信息
admin := repositories.Admin().GetAdminInfo(fmt.Sprintf("%s", data["user"]))

我这里举例只调用了子仓储的一个方法,你还可以通过repositories.Admin()去调用公共方法。当然,如果上面已经封装好的方法仍然无法满足你的需求,你可以在子仓储中使用GORM模型进行扩展,GORM更多方法:https://gorm.io/zh_CN/docs/

8. 数据库模型

每个模型对应一张数据表,结构体成员采用大驼峰命名,json标签的反射字段采用小驼峰命名,gorm标签的column属性命名与数据库字段对应。例子如下:

package models

import (
	"gorm.io/gorm"
	"time"
)

// Admin 管理员结构体
type Admin struct {
	Id           uint           `json:"id" gorm:"column:id;primary_key;comment:管理员ID"`
	User         string         `json:"user" gorm:"column:user;comment:管理员用户名"`
	Password     string         `json:"password" gorm:"column:password;comment:管理员密码"`
	Photo        string         `json:"photo" gorm:"column:photo;comment:管理员头像"`
	Name         string         `json:"name" gorm:"column:name;comment:真实姓名"`
	Card         string         `json:"card" gorm:"column:card;comment:身份证号码"`
	Sex          uint8          `json:"sex" gorm:"column:sex;default:3;comment:性别(1为女,2为男,3为保密)"`
	Age          uint8          `json:"age" gorm:"column:age;default:0;comment:年龄"`
	Region       string         `json:"region" gorm:"column:region;comment:地区"`
	Phone        string         `json:"phone" gorm:"column:phone;comment:手机号码"`
	Email        string         `json:"email" gorm:"column:email;comment:邮箱"`
	Introduction string         `json:"introduction" gorm:"column:introduction;comment:简介"`
	State        uint8          `json:"state" gorm:"column:state;default:2;comment:状态(1已停用,2已启用)"`
	Sort         uint           `json:"sort" gorm:"column:sort;default:0;comment:排序"`
	LoginOutIp   string         `json:"loginOutIp" gorm:"column:login_out_ip;comment:上一次登录IP地址"`
	LoginTotal   uint           `json:"loginTotal" gorm:"column:login_total;default:0;comment:登录总数"`
	LoginOutAt   time.Time      `json:"loginOutAt" gorm:"column:login_out_at;default:1970-01-01 00:00:00;comment:上一次登录时间"`
	CreatedAt    time.Time      `json:"createdAt" gorm:"column:created_at;comment:创建时间"`
	UpdatedAt    time.Time      `json:"updatedAt" gorm:"column:updated_at;comment:更新时间"`
	DeletedAt    gorm.DeletedAt `json:"deletedAt" gorm:"column:deleted_at;index;comment:删除时间"`
}

// TableName 设置用户表名
func (Admin) TableName() string {
	return "admin"
}

其中TableName()是必须的,需要返回一个string类型为数据表名称。

9. 响应

统一响应方法在/app/response.go,代码如下:

package app

import (
	"fmt"
	"github.com/herman-hang/herman/app/constants"
	"github.com/herman-hang/herman/app/utils"
	"net/http"
)

// Response 响应信息结构体
type Response struct {
	HttpCode int         `json:"-"`
	Code     int         `json:"code"`
	Message  string      `json:"message"`
	Data     interface{} `json:"data"`
}

// Option 定义配置选项函数(关键)
type Option func(*Response)

// C 设置JSON结构状态码
// @param int code 状态码
// @return Option 返回配置选项函数
func C(code int) Option {
	return func(this *Response) {
		this.Code = code
	}
}

// M 设置响应信息
// @param string message 自定义响应信息
// @return Option 返回配置选项函数
func M(message string) Option {
	return func(this *Response) {
		this.Message = message
	}
}

// D 设置响应参数
// @param interface{} data 响应数据
// @return Option 返回配置选项函数
func D(data interface{}) Option {
	return func(this *Response) {
		this.Data = data
	}
}

// H 设置HTTP响应状态码
// @param int HttpCode HTTP状态码,比如:200,500等
// @return Option 返回配置选项函数
func H(HttpCode int) Option {
	return func(this *Response) {
		this.HttpCode = HttpCode
	}
}

// Success 方法一:响应函数
// @param *Gin g 上下文结构体
// @param Option opts 接收多个配置选项函数参数,可以是C,M,D,H
func (r *Request) Success(opts ...Option) {
	defaultResponse := &Response{
		HttpCode: http.StatusOK,
		Code:     http.StatusOK,
		Message:  constants.Success,
		Data:     nil,
	}

	// 依次调用opts函数列表中的函数,为结构体成员赋值
	for _, o := range opts {
		o(defaultResponse)
	}
	// 响应http请求
	r.Context.JSON(defaultResponse.HttpCode, defaultResponse)
	return
}

// Json 方法二:响应函数(所有字段转小驼峰写法)
// @param interface{} data 接收响应参数
// @param args 第一个参数为message,第二个参数为code
func (r *Request) Json(data interface{}, args ...interface{}) {
	var jsonString []byte
	// 将数据转为json格式返回
	camelJson, _ := utils.CamelJSON(data)
	switch len(args) {
	case 0:
		jsonString = []byte(fmt.Sprintf(`{"code":%d,"message":"%s","data":%s}`, http.StatusOK, constants.Success, camelJson))
	case 1:
		jsonString = []byte(fmt.Sprintf(`{"code":%d,"message":"%s","data":%s}`, http.StatusOK, args[0], camelJson))
	case 2:
		jsonString = []byte(fmt.Sprintf(`{"code":%d,"message":"%s","data":%s}`, args[1], args[0], camelJson))
	}
	// 响应http请求
	r.Context.Data(http.StatusOK, "application/json", jsonString)
}

目前响应json有2种方法,下面是其中一种:

	// 测试路由
	rootEngine.GET("/", func(context *gin.Context) {
		response := app.Request{Context: context}
		response.Success(app.D(map[string]interface{}{
			"message": "Welcome to Herman!",
		}))
	})

其中response.Success()参数中可以接收4个参数,每一个参数都是响应方法中的一个函数。比如上面就只调用了一个函数app.D(),根据业务需求,你还可以在response.Success()追加其他函数进去,比如app.H()app.C()app.M()

另一种方法就是直接Json,比如:

func Login(ctx *gin.Context) {
   context := app.Request{Context: ctx}
   data := context.Params()
   context.Json(AdminService.Login(AdminValidate.Login(data)), AdminConstant.LoginSuccess)
}

context.Json()方法第一个参数是data,第二个参数是message,第三个参数是code,必须严格根据这个顺序来传入,否则出错。

10. 测试

单元测试核心代码位于/tests/base.go,单元测试比较推荐使用套件测试,每个模块需要在tests目录下进行创建,这个模块建议和控制器一一对应。值得注意的是,单元测试支持多应用测试,在做HTTP测试的时候,登录方法都需要封装在/tests/base.go中,比如框架中的管理员登录:

// AdminLogin 管理员登录
// @return void
func (s *SuiteCase) AdminLogin() {
	var (
		response app.Response
		loginUri = s.AppPrefix + "/admin/login"
	)
	// map转json
	_, _, w := s.Request("POST", loginUri, map[string]interface{}{
		"user":     "admin",
		"password": "123456",
	})
	// json转struct
	_ = json.Unmarshal(w.Body.Bytes(), &response)
	s.Authorization = response.Data.(string)
}

封装后登录之后,需要在SetupSuite()方法中作逻辑处理,如下:

// SetupSuite 测试套件前置函数
// @return void
func (s *SuiteCase) SetupSuite() {
	app.InitConfig()
	servers.ZapLogs()
	middlewares.Reload()
	gin.SetMode(app.Config.Mode)
	e := gin.Default()
	e.Use(middlewares.CatchError())
	core.Engine = routers.InitRouter(e)
	s.AppPrefix = app.Config.AppPrefix
	switch s.Guard {
	case "admin":
		s.AdminLogin()
	default:
		panic(MiddlewareConstant.GuardError)
	}
}

这样就可以在单测里面调用来的,每个单元测试都有一个测试套件方法,如下:

// TestAdminTestSuite 管理员测试套件
// @return void
func TestAdminTestSuite(t *testing.T) {
   suite.Run(t, &AdminTestSuite{SuiteCase: test.SuiteCase{Guard: "admin"}})
}

这里根据业务是必须定义的,实例化结构体要根据业务需求随之应变。Guard: "admin"表示使用管理员登录方法,整个套件测试例子如下:

package admin

import (
   "fmt"
   "github.com/brianvoe/gofakeit/v6"
   "github.com/herman-hang/herman/app/repositories"
   "github.com/herman-hang/herman/kernel/core/test"
   "github.com/herman-hang/herman/database/seeders/admin"
   "github.com/herman-hang/herman/database/seeders/role"
   "github.com/stretchr/testify/suite"
   "testing"
)

// 管理员测试套件结构体
type AdminTestSuite struct {
   test.SuiteCase
}

var (
   AdminLoginUri = "/admin/login"  // 管理员登录URI
   AdminUri      = "/admin/admins" // 管理员URI
)

// TestLogin 测试管理员登录
// @return void
func (base *AdminTestSuite) TestLogin() {
   base.Assert([]test.Case{
      {
         Method:  "POST",
         Uri:     base.AppPrefix + AdminLoginUri,
         Params:  map[string]interface{}{"user": "admin", "password": "123456"},
         Code:    200,
         Message: "登录成功",
      },
   })
}

// TestAddAdmin 测试添加管理员
// @return void
func (base *AdminTestSuite) TestAddAdmin() {
   roleInfo, _ := repositories.Role().Insert(role.Role())
   adminInfo := admin.Admin()
   adminInfo["roles"] = []map[string]interface{}{
      {
         "name": roleInfo["name"].(string),
         "role": roleInfo["role"].(string),
      },
   }
   base.Assert([]test.Case{
      {
         Method:  "POST",
         Uri:     base.AppPrefix + AdminUri,
         Params:  adminInfo,
         Code:    200,
         Message: "操作成功",
      },
   })
}

// TestModifyAdmin 测试修改管理员
// @return void
func (base *AdminTestSuite) TestModifyAdmin() {
   roleInfo, _ := repositories.Role().Insert(role.Role())
   adminInfo := admin.Admin()
   adminInfo["roles"] = []map[string]interface{}{
      {
         "name": roleInfo["name"].(string),
         "role": roleInfo["role"].(string),
      },
   }
   info, _ := repositories.Admin().Insert(adminInfo)
   base.Assert([]test.Case{
      {
         Method: "PUT",
         Uri:    base.AppPrefix + AdminUri,
         Params: map[string]interface{}{
            "id":           info["id"],
            "user":         gofakeit.Username(),
            "password":     gofakeit.Password(false, false, true, false, false, 10),
            "photo":        gofakeit.ImageURL(100, 100),
            "roles":        adminInfo["roles"],
            "name":         gofakeit.Name(),
            "card":         "450981200008272525",
            "sex":          gofakeit.RandomInt([]int{1, 2, 3}),
            "age":          gofakeit.Number(18, 60),
            "region":       gofakeit.Country(),
            "phone":        "18888888888",
            "email":        gofakeit.Email(),
            "introduction": gofakeit.Sentence(10),
            "state":        gofakeit.RandomInt([]int{1, 2}),
            "sort":         gofakeit.Number(1, 100),
         },
         Code:    200,
         Message: "操作成功",
      },
   })
}

// TestDeleteAdmin 测试根据ID获取管理员详情
// @return void
func (base *AdminTestSuite) TestFindAdmin() {
   roleInfo, _ := repositories.Role().Insert(role.Role())
   adminInfo := admin.Admin()
   adminInfo["roles"] = []map[string]interface{}{
      {
         "name": roleInfo["name"].(string),
         "role": roleInfo["role"].(string),
      },
   }
   info, _ := repositories.Admin().Insert(adminInfo)
   base.Assert([]test.Case{
      {
         Method:  "GET",
         Uri:     base.AppPrefix + AdminUri + "/" + fmt.Sprintf("%d", info["id"]),
         Params:  nil,
         Code:    200,
         Message: "操作成功",
      },
   })
}

// TestGetAdminList 测试删除管理员
// @return void
func (base *AdminTestSuite) TestRemoveAdmin() {
   roleInfo, _ := repositories.Role().Insert(role.Role())
   adminInfo := admin.Admin()
   adminInfo["roles"] = []map[string]interface{}{
      {
         "name": roleInfo["name"].(string),
         "role": roleInfo["role"].(string),
      },
   }
   info, _ := repositories.Admin().Insert(adminInfo)
   base.Assert([]test.Case{
      {
         Method: "DELETE",
         Uri:    base.AppPrefix + AdminUri,
         Params: map[string]interface{}{
            "id": []uint{info["id"].(uint)},
         },
         Code:    200,
         Message: "操作成功",
      },
   })
}

// TestGetAdminList 测试获取管理员列表
// @return void
func (base *AdminTestSuite) TestListAdmin() {
   roleInfo, _ := repositories.Role().Insert(role.Role())
   adminInfo := admin.Admin()
   adminInfo["roles"] = []map[string]interface{}{
      {
         "name": roleInfo["name"].(string),
         "role": roleInfo["role"].(string),
      },
   }
   _, _ = repositories.Admin().Insert(adminInfo)
   base.Assert([]test.Case{
      {
         Method:  "GET",
         Uri:     base.AppPrefix + AdminUri,
         Params:  map[string]interface{}{"page": 1, "pageSize": 2, "keywords": ""},
         Code:    200,
         Message: "操作成功",
         IsList:  true,
         Fields: []string{
            "id",
            "user",
            "photo",
            "sort",
            "state",
            "phone",
            "email",
            "name",
            "card",
            "introduction",
            "sex",
            "age",
            "region",
            "createdAt",
         },
      },
   })
}

// TestAdminTestSuite 管理员测试套件
// @return void
func TestAdminTestSuite(t *testing.T) {
   suite.Run(t, &AdminTestSuite{SuiteCase: test.SuiteCase{Guard: "admin"}})
}

11. 数据库迁移

目前数据库迁移功能已经非常强大,支持数据库版本更新,版本回滚,强制删除,更新回滚指定版本等等,数据库迁移文件位于/database/migrations目录下,命名格式版本号_文件描述_迁移属性.sql,其中版本号可以自定义,推荐用自然数自增,避免出现不可预估的问题,迁移文件必须是成对存在的,有迁移文件就必须有回滚文件,比如创建了一个迁移文件1_init.up.sql,那么回滚文件1_init.down.sql就必须存在,并且文件内容(DDL)不能为空,否则在操作数据库迁移时会出错。根据需求可以执行以下命令:

  • 数据库迁移
herman migrate --status=true --direction=up

简写:

herman migrate -s true

或:

herman migrate -s true -d up
  • 数据库回滚
herman migrate --status=true --direction=down

简写:

herman migrate -s true -d down
  • 强制执行指定版本的文件
herman migrate --status=true --direction=force --version=1 # 强制执行版本号为1的迁移文件

简写:

herman migrate -s true -d force -v 1 # 强制执行版本号为1的迁移文件
  • 迁移1个版本
herman migrate --status=true --direction=up --number=1

简写:

herman migrate -s true -d up -n 1
  • 回滚1个版本
herman migrate --status=true --direction=down --number=1

简写:

herman migrate -s true -d down -n 1
  • 强制删除数据库
herman migrate --status=true --direction=drop

简写:

herman migrate -s true -d drop

12. 数据填充

数据填充位于/database/seeders,该目录下的每个模块都是对应于控制器。数据填充采用的是brianvoe/gofakeit,填充例子如下:

package admin

import (
   "github.com/brianvoe/gofakeit/v6"
)

// Admin 管理员填充器
func Admin() map[string]interface{} {
   return map[string]interface{}{
      "user":         gofakeit.Username(),
      "password":     gofakeit.Password(false, false, true, false, false, 10),
      "photo":        gofakeit.ImageURL(100, 100),
      "name":         gofakeit.Name(),
      "card":         "450981200008272525",
      "sex":          gofakeit.RandomInt([]int{1, 2, 3}),
      "age":          gofakeit.Number(18, 60),
      "region":       gofakeit.Country(),
      "phone":        "18888888888",
      "email":        gofakeit.Email(),
      "introduction": gofakeit.Sentence(10),
      "state":        gofakeit.RandomInt([]int{1, 2}),
      "sort":         gofakeit.Number(1, 100),
   }
}

更多规则:https://github.com/brianvoe/gofakeit

13. 验证码

目前支持的验证码有两种方式,1:滑动验证码,2:文字点选验证码,Herman已经集成接口的形式,可以直接使用,当然,你也可以自己扩展,例子:

  • 配置
// WatermarkConfig 水印设置
type WatermarkConfig struct {
	FontSize int   // 水印字体大小
	Color    color.RGBA  // 水印rgba颜色
	Text     string // 水印文字
}

type BlockPuzzleConfig struct {
	Offset int // 校验时 容错偏移量
}

type ClickWordConfig struct {
	FontSize int // 点击验证文字的大小
	FontNum  int // 点击验证的文字的随机数量 
}

type Config struct {
	Watermark      *WatermarkConfig
	ClickWord      *ClickWordConfig
	BlockPuzzle    *BlockPuzzleConfig
	CacheType      string // 验证码使用的缓存类型
	CacheExpireSec int
}

func NewConfig() *Config {
	return &Config{
		CacheType: "redis",  // 注册的缓存类型
		Watermark: &WatermarkConfig{
			FontSize: 12,
			Color:    color.RGBA{R: 255, G: 255, B: 255, A: 255},
			Text:     "我的水印",
		},
		ClickWord: &ClickWordConfig{
			FontSize: 25,
			FontNum:  5,
		},
		BlockPuzzle:    &BlockPuzzleConfig{Offset: 10},
		CacheExpireSec: 2 * 60, // 缓存有效时间
	}
}
  • 使用
package main

import (
	config2 "github.com/TestsLing/aj-captcha-go/config"
	"github.com/TestsLing/aj-captcha-go/service"
	"github.com/TestsLing/aj-captcha-go/const"
	"github.com/gin-gonic/gin"
)

// 客户端参数 看自身业务构建即可
type clientParams struct {
	Token       string `json:"token"`
	PointJson   string `json:"pointJson"`
	CaptchaType string `json:"captchaType"`
}

// 默认配置,可以根据项目自行配置,将其他类型配置序列化上去
var config = config2.NewConfig()
// 服务工厂,主要用户注册 获取 缓存和验证服务
var factory = service.NewCaptchaServiceFactory(config)

func main() {

	// 这里默认是注册了 内存缓存,但是不足以应对生产环境,希望自行注册缓存驱动 实现缓存接口即可替换(CacheType就是注册进去的 key)
	factory.RegisterCache(constant.MemCacheKey, service.NewMemCacheService(20)) // 这里20指的是缓存阈值
	
	// 注册了两种验证码服务 可以自行实现更多的验证
	factory.RegisterService(constant.ClickWordCaptcha, service.NewClickWordCaptchaService(factory))
	factory.RegisterService(constant.BlockPuzzleCaptcha, service.NewBlockPuzzleCaptchaService(factory))

	//Default返回一个默认的路由引擎
	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		//输出json结果给调用方
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})
	r.GET("/captcha/get", func(c *gin.Context) {
		// 根据参数类型获取不同服务即可
		data := factory.GetService(config2.BlockPuzzleCaptcha).Get()
		//输出json结果给调用方
		c.JSON(200, data)
	})
	r.Run("0.0.0.0:888") // listen and serve on 0.0.0.0:888
}

更多开发文档:https://ajcaptcha.beliefteam.cn/captcha-doc/

14. License

Apache License Version 2.0 see http://www.apache.org/licenses/LICENSE-2.0.html

Documentation

The Go Gopher

There is no documentation for this package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL