Gin微服务搭建
这算我是第一个相对全面的Golang微服务了,事后复盘起来还是发现了很多不合理的地方
比如middleware中和token相关的Casbin权限校验和Token鉴权不应该重复的解析两次Token
以及其实最后并没真正的做成微服务,服务发现、注册和grpc这些都没有接入
原因是部门经理又把技术栈切换到Java8的单体服务了,悲: (
1. 介绍
- 需求: 8个工作日的时间,快速迭代出一期版本,包含最基本的一些功能即可
- 之前的服务是用JDK17+SpringBoot+SpringCloud做的,分了六个微服务,但是实际上想要容器化部署的话,单个服务启动的内存都已经突破1G了
- 因为是新的项目,而且主要以演示为主,且之前搭建网址库也储备了一点经验,所以想试试用Go来做一下
2. 整体架构
这里的数据库方面涉及公司业务,只保留了一些最基本的部分
下面抽出一些有意思的地方记录一下
2.1 middleware
首先是Gin的middleware部分,这里用了五个middleware:
- log: 日志打印,这里首先方通了所有的请求,然后会记录请求成功的日志。不过,因为运维的要求,接口中是只能用Get和Post的,所以不记录其他的请求方法。
- cors: 用于设置跨域, 这里主要是约定了Header中需要传tenantId,来区分当前的租户
- token鉴权: 判断token
- Casbin权限校验: 用于校验token是否拥有一些接口的权限
- Tenant租户隔离: 用于区分租户
// LogMiddleware
//
// @Description: 日志记录, 记录POST和GET的日志
// @return gin.HandlerFunc
func LogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if c.Writer.Status() == e.SUCCESS {
var content string
opType := utils.GetOpType(c)
content = utils.GetOpContent(c)
if opType == "" {
return
}
tenantId := utils.GetTenantId(c)
remoteAddr := c.Request.RemoteAddr
tenantName := tenant_service.GetNameByTenantId(tenantId)
if content == "" {
switch c.Request.Method {
case "GET":
// GET请求查PARAMS
params := c.Request.URL.Query()
for key, values := range params {
for _, value := range values {
content += fmt.Sprintf("%s=%s,", key, value)
}
}
case "POST":
// POST请求查BODY
form := c.Request.PostForm
for key, values := range form {
for _, value := range values {
content += fmt.Sprintf("%s=%s,", key, value)
}
}
default:
return
}
}
log_service.Add(models.Log{
Model: models.Model{TenantID: tenantId},
Type: opType,
TenantName: tenantName,
RemoteAddr: remoteAddr,
LogContent: content,
})
}
}
}
// CORSMiddleware
//
// @Description: 用于设置 CORS 配置
// @return gin.HandlerFunc
//
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, " +
"X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, token, tenantId")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, HEAD")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(e.NO_CONTENT)
return
}
c.Next()
}
}
// JwtMiddleware
//
// @Description: JWT鉴权
// @return gin.HandlerFunc
func JwtMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
var code int
var data interface{}
code = e.SUCCESS
token := c.Request.Header.Get("Token")
if token == "" {
code = e.PARAMS_ERROR
} else {
err := utils.ValideToken(token)
if err != nil {
switch err.(*jwt.ValidationError).Errors {
case jwt.ValidationErrorExpired:
code = e.UNAUTHORIZED
default:
code = e.UNAUTHORIZED
}
}
}
if code != e.SUCCESS {
c.AbortWithStatus(code)
return
}
c.Next()
}
}
// CasbinMiddleWire
//
// @Description: 用于接口权限检查
// @param cas
// @return gin.HandlerFunc
func CasbinMiddleWire(cas *casbin.Service) gin.HandlerFunc {
return func(c *gin.Context) {
err := cas.Enforcer.LoadPolicy()
if err != nil {
c.AbortWithStatus(e.UNKNOWN_EXCEPTION)
return
}
user, path, method := utils.ParseToken(token)
ok, err := cas.Enforcer.Enforce(user, path, method)
if err != nil {
c.AbortWithStatus(e.UNKNOWN_EXCEPTION)
return
} else if !ok {
c.AbortWithStatus(e.FAILED)
return
}
c.Next()
}
}
// TenantMiddleware
//
// @Description: 获取Header中TenantID的中间件
// @return gin.HandlerFunc
func TenantMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 从请求头中获取 tenantId
tenantId := c.Request.Header.Get("tenantId")
if tenantId == "" {
// 如果 tenantId 为空,返回错误响应
c.JSON(http.StatusBadRequest, gin.H{
"code": e.PARAMS_ERROR,
"msg": "tenantId is required",
})
c.Abort()
return
}
// 将 tenantId 存储到 gin.Context 中,供后续使用
c.Set(constants.ContextTenantId, tenantId)
// 继续处理下一个中间件或控制器函数
c.Next()
}
}
2.2 GORM公共方法的封装
除了基本的CRUD封装外,这里封装了一个模糊搜索的功能,只需要在model的注释中添加search字段即可 这样在调用Ppage方法的时候,会自动的以递归的方式查找当前struct的所有结构下的search字段,并添加至sql语句中
// Ppage[T any]
//
// @Description: 分页查找, 支持分页、时间过滤、模糊搜索
// @param tenantId
// @param t
// @param result
// @param p
// @return error
//
// e.g:
// 需要搜索的字段添加 search: true, 仅支持嵌套、多字段搜索
//
// type Log struct {
// Model
//
// Type string `json:"type"`
// TenantName string `json:"tenant_name"`
// RemoteAddr string `json:"remote_addr" search:"true"`
// LogContent string `json:"log_content" search:"true"`
// }
func Ppage[T any](tenantId int, t T, result *[]T, p *base_req.PageReq) error {
startTime, _ := time.Parse(time.DateTime, p.StartTime)
endTime, _ := time.Parse(time.DateTime, p.EndTime)
offset := (p.PageNum - 1) * p.PageSize
query := db.Where("tenant_id = ?", tenantId)
// 如果提供了时间区间,则添加过滤条件
if !startTime.IsZero() && !endTime.IsZero() {
query = query.Where("create_time BETWEEN ? AND ?", startTime, endTime)
}
// 模糊搜索
if p.Search != "" {
searchFields := getSearchField(t)
if len(searchFields) > 0 {
query = query.Where(buildSearchQuery(searchFields, p.Search))
}
}
// 记录总数
if err := query.Model(&t).Count(&p.Total).Error; err != nil {
logging.Error(err)
return err
}
// 页总数
p.PageCount = int64(math.Ceil(float64(p.Total) / float64(p.PageSize)))
// 分页查询
if err := query.Order("id desc").Offset(offset).Limit(p.PageSize).Find(result).Error; err != nil {
return err
}
return nil
}
// getSearchField
//
// @Description: 获取结构体对应带有 "search" 标签的 json 内容, 用于模糊搜索
// @param data
// @return []string
func getSearchField(data interface{}) []string {
v := reflect.Indirect(reflect.ValueOf(data))
return getSearchFieldRecursive(v)
}
// getSearchField
//
// @Description: 获取结构体对应带有search的json内容, 用于模糊搜索
// @param data
// @return string
//
// getSearchFieldRecursive
//
// @Description: 递归地从结构体中获取带有 "search" 标签的字段
// @param v reflect.Value
// @return []string
func getSearchFieldRecursive(v reflect.Value) []string {
var searchFields []string
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
tag := v.Type().Field(i).Tag
if tag.Get("search") == "true" {
searchFields = append(searchFields, tag.Get("json"))
}
if field.Kind() == reflect.Struct {
searchFields = append(searchFields, getSearchFieldRecursive(field)...)
}
}
return searchFields
}
2.3 循环定时服务
2.3.1 轮询
需求:
- 用户可以随时从前端修改循环任务
- 在一个任务内,以周为单位进行循环,比如第一周的周一13点执行,那么第二周的周一13点也需要执行
- 用户在一周内可以拥有多个循环任务,比如一周内可以每天都有循环任务,同时每天也可以设置多个
既然如此,那么直接用@Scheduled写在程序里显然是不合适的,因为用户修改了前端,总不可能运维手动去改yaml文件再重启后端。
那么将任务先存一下,然后定时再查询,不就可以解决了吗
最初确实是这样做的,每n秒去读取一下当前的任务表,发现有临近n秒的任务则取出来执行。但是这样做需要遍历整个任务表,查询的次数过多。
2.3.2 优先队列
使用优先队列可以优化上述遍历任务表的过程,优先队列因为小顶堆的特性,可以迅速的弹出符合当前时间判断的任务,并且当队头的任务不符合时间的情况下,后续的任务一定不符合,无需遍历判断
那么此时产生一个问题,如果用户定义了10年的循环期限,总不可能一次性都把所有的任务都解析完再丢进优先队列吧?
所以对于一个任务队列(周一13:00,周三14:00,周日4:00),只再队列中保留下一个需要执行的任务即可,比如假设7月1日是周一,只保留7月1日13:00,当执行完7月1肉13:00的任务后 再去任务配置中找到下一个任务即7月3日14:00加入优先队列
2.3.3 如何找到下一个任务
如果用户在每周的每天都定义了很多任务,那么用遍历方法找到下一个任务的话,依旧是太费时间了
所以,这里依旧是以空间换时间的思想,将周的区间映射为数组,而任务的时间为时间戳,对周的区间取模,再判断落点即可
依旧是上面(周一13:00,周三14:00,周日4:00)的例子:
- 周的区间为
60*60*24*7=604800
- (周一13:00,周三14:00,周日4:00)所以对应的数组为
[46800,223200,532800]
- 此时只要将当前的时间转换为秒为单位的时间戳,再对
604800
取模,即可知道当前所在的时间区间 - 最后选择下一个区间的任务即可
3. 容器化部署
最后一部分是总结一下部署,这里主要还是延续了原Java项目中的部署结果,即前端镜像内置nginx,使用同一端口的不同路径分别代理前后端
注:既然此处只对外放出一个端口,那么为什么还要在后端增加额外的跨域配置呢?因为公司的运维说法真是太多了,而我们的产品又趋向于私有化部署,他们更喜欢配置成多个端口
最后贴一个前后端(Vue+Golang)的Dockerfile
# 在此之前需要先 go build 一下
FROM alpine:3.20
LABEL authors="pptg"
ENV GOPROXY https://goproxy.cn,direct
WORKDIR $GOPATH/src/lssw-server
# 编译后的程序
COPY lssw-server $GOPATH/src/lssw-server
# 配置文件
COPY config.yml $GOPATH/src/lssw-server
# 暴露端口
EXPOSE 8000
# 启动
ENTRYPOINT ["./lssw-server"]
# 在此之前需要先 npm run build 一下
FROM nginx:1.24
LABEL authors="pptg"
# 删除nginx默认文件
RUN rm /etc/nginx/conf.d/default.conf
# https证书
COPY ./docker-build/https-sign /etc/nginx
# npm run build后的页面
COPY ./dist/ /usr/share/nginx/html/
# nginx配置
COPY ./docker-build/nginx.conf /etc/nginx/nginx.conf
COPY docker-build/lssw.conf /etc/nginx/conf.d/
# 暴露端口
EXPOSE 80 443
# 启动
CMD ["nginx","-g","daemon off;"]