跳至主要內容

Gin微服务搭建

pptg大约 8 分钟

这算我是第一个相对全面的Golang微服务了,事后复盘起来还是发现了很多不合理的地方

比如middleware中和token相关的Casbin权限校验和Token鉴权不应该重复的解析两次Token

以及其实最后并没真正的做成微服务,服务发现、注册和grpc这些都没有接入

原因是部门经理又把技术栈切换到Java8的单体服务了,悲: (

1. 介绍

  • 需求: 8个工作日的时间,快速迭代出一期版本,包含最基本的一些功能即可
  • 之前的服务是用JDK17+SpringBoot+SpringCloud做的,分了六个微服务,但是实际上想要容器化部署的话,单个服务启动的内存都已经突破1G了
  • 因为是新的项目,而且主要以演示为主,且之前搭建网址库也储备了一点经验,所以想试试用Go来做一下

2. 整体架构

这里的数据库方面涉及公司业务,只保留了一些最基本的部分

lssw-server.png
lssw-server.png

下面抽出一些有意思的地方记录一下

2.1 middleware

首先是Gin的middleware部分,这里用了五个middleware:

  1. log: 日志打印,这里首先方通了所有的请求,然后会记录请求成功的日志。不过,因为运维的要求,接口中是只能用Get和Post的,所以不记录其他的请求方法。
  2. cors: 用于设置跨域, 这里主要是约定了Header中需要传tenantId,来区分当前的租户
  3. token鉴权: 判断token
  4. Casbin权限校验: 用于校验token是否拥有一些接口的权限
  5. Tenant租户隔离: 用于区分租户
log日志记录
// 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,
			})
		}
	}
}

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,使用同一端口的不同路径分别代理前后端

lssw-server部署.png
lssw-server部署.png

注:既然此处只对外放出一个端口,那么为什么还要在后端增加额外的跨域配置呢?因为公司的运维说法真是太多了,而我们的产品又趋向于私有化部署,他们更喜欢配置成多个端口

最后贴一个前后端(Vue+Golang)的Dockerfile

Golang
# 在此之前需要先 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"]