【Go项目基建】GORM框架实现SQL校验拦截器(完整源码+详解)

发布于:2025-09-07 ⋅ 阅读:(20) ⋅ 点赞:(0)

背景

阅读之前实习的公司里的文档观察到,之前组内的同事在Java项目当中编写了一个基于Mybatis和JSQLParser SQL开发的SQL统一校验拦截器,有效的规避了一定程度上的慢查问题

考虑到部门内相关的新业务功能都在Go当中开发

因此也针对以下情况

  1. 列表查询无查询条件导致全表扫描,
  2. 查询SQL复杂关联多表查询

设计一个封装统一的SQL校验工具引入项目中避免慢SQL产生,以减少整个部门技术SQL慢查数量

功能介绍

  1. 支持GORM持久层框架的SQL查询
  2. 校验SQL语句是否具备where查询条件
  3. 校验SQL语句where查询条件是否为有效查询(排除 where 1=1, where is_deleted="N" 此类)
  4. 支持指定白名单表不校验
  5. 支持校验开关配置
  6. 支持多表关联查询限制连接表数量
  7. 项目启动时,会生成日志记录

TODO

  1. 无索引,索引不生效的情况,拿到表的索引,与当前的SQL进行分析
  2. 数据量每天统计增长情况,每张表定一个数据量级,增长趋势,量级预警报告

版本

1.0.0

sql拦截校验,白名单表配置,多表关联查询限制数量

如何使用

1. 新建文件,复制校验代码

在util/component包下新建一个go文件

package util

import (
    "app/config/logger"
    "fmt"
    "regexp"
    "strings"

    "gorm.io/gorm"
)

// SQLValidatorPlugin GORM SQL校验插件(避免全表扫描和过多表关联)
type SQLValidatorPlugin struct {
    allowList    map[string]bool // 允许全表查询的表名白名单(不区分大小写)
    maxJoinCount int             // 最大允许的表关联数量
}

// NewSQLValidatorPlugin 创建插件实例
// allowList:允许全表查询的表名列表
// maxJoinCount:最大允许的表关联数量,0表示不限制
func NewSQLValidatorPlugin(maxJoinCount int, allowList []string) *SQLValidatorPlugin {
    allowMap := make(map[string]bool, len(allowList))
    for _, table := range allowList {
        allowMap[strings.ToLower(table)] = true
    }
    // 输出启动日志
    logger.Info("SQL校验拦截器已开启,配置信息:最大表关联数量=%d,白名单表=%v", maxJoinCount, allowList)
    return &SQLValidatorPlugin{
        allowList:    allowMap,
        maxJoinCount: maxJoinCount,
    }
}

func (p *SQLValidatorPlugin) Name() string {
    return "sql_validator"
}

// Initialize 初始化插件
func (p *SQLValidatorPlugin) Initialize(db *gorm.DB) error {
    // 将回调注册在"gorm:prepare_stmt"之后,此时SQL已初步构建
    return db.Callback().Query().After("gorm:prepare_stmt").Register(p.Name(), func(db *gorm.DB) {
        if err := p.validateSQL(db); err != nil {
            logger.Errorf("SQL校验失败: %v, 具体SQL: %s", err, db.Dialector.Explain(db.Statement.SQL.String(), db.Statement.Vars...))
            db.AddError(err)
        }
    })
}

// 预编译正则
var (
    selectRegex            = regexp.MustCompile(`(?i)^SELECT`)
    whereRegex             = regexp.MustCompile(`(?i)\bWHERE\b`)
    limitOneRegex          = regexp.MustCompile(`(?i)LIMIT\s+1`)
    fromTableRegex         = regexp.MustCompile(`(?i)FROM\s+([^\s,]+)`)
    identityConditionRegex = regexp.MustCompile(`(?i)WHERE\s+(\d+)\s*=+\s*\\1\s*(AND|OR|$)`)
    isDeletedOnlyRegex     = regexp.MustCompile(`(?i)WHERE\s*([\w]+\.)?is_deleted\s*=`)
    hasLogicOpRegex        = regexp.MustCompile(`(?i)\bAND\b|\bOR\b`)
    joinRegex              = regexp.MustCompile(`(?i)\b(INNER|LEFT|RIGHT|FULL|CROSS)\s+JOIN\b|\bJOIN\b`)
)

// validateSQL 核心校验逻辑(使用GORM原生方法获取完整SQL)
func (p *SQLValidatorPlugin) validateSQL(db *gorm.DB) error {
    if db.Error != nil {
        return nil
    }

    // 通过Dialector.Explain获取带参数的完整SQL
    fullSQL := db.Dialector.Explain(db.Statement.SQL.String(), db.Statement.Vars...)
    if fullSQL == "" {
        // SQL未生成,跳过校验
        return nil
    }

    cleanSQL := removeComments(fullSQL)

    // 1. 检查多表关联数量
    if p.maxJoinCount > 0 {
        joinCount := p.countJoins(cleanSQL)
        if joinCount > p.maxJoinCount {
            return fmt.Errorf("表关联数量超过限制(最大允许: %d, 实际: %d),具体SQL: %s",
                              p.maxJoinCount, joinCount, cleanSQL)
        }
    }

    // 2. 基础规则校验
    isSelect := selectRegex.MatchString(cleanSQL)
    hasWhere := whereRegex.MatchString(cleanSQL)
    hasLimitOne := limitOneRegex.MatchString(cleanSQL)
    isOnlyIdentity := hasWhere && identityConditionRegex.MatchString(cleanSQL)
    isOnlyIsDeleted := false
    if hasWhere && isDeletedOnlyRegex.MatchString(cleanSQL) {
        wherePart := p.extractWhereClause(cleanSQL)
        isOnlyIsDeleted = !hasLogicOpRegex.MatchString(wherePart)
    }

    if !isSelect || hasLimitOne || (!isOnlyIdentity && !isOnlyIsDeleted) {
        return nil
    }

    // 3. 白名单校验
    tableName := extractMainTableName(cleanSQL)
    if tableName != "" && p.allowList[strings.ToLower(tableName)] {
		return nil
	}

	return fmt.Errorf("禁止执行等效全表扫描的SELECT查询,具体SQL: %s", cleanSQL)
}

// 计算表关联数量
func (p *SQLValidatorPlugin) countJoins(sql string) int {
	// 查找所有匹配的JOIN关键字
	matches := joinRegex.FindAllString(sql, -1)
	return len(matches)
}

// 提取WHERE子句
func (p *SQLValidatorPlugin) extractWhereClause(sql string) string {
	whereIdx := strings.Index(strings.ToUpper(sql), "WHERE")
	if whereIdx == -1 {
		return ""
	}
	whereContent := sql[whereIdx+5:]
	for _, clause := range []string{"GROUP BY", "ORDER BY", "LIMIT", "OFFSET"} {
		if idx := strings.Index(strings.ToUpper(whereContent), clause); idx != -1 {
			whereContent = whereContent[:idx]
		}
	}
	return strings.TrimSpace(whereContent)
}

// 移除注释
func removeComments(sql string) string {
	re := regexp.MustCompile(`(?s)(--.*?\n|/\*.*?\*/)`)
	return re.ReplaceAllString(sql, " ")
}

// 提取主表名
func extractMainTableName(sql string) string {
	matches := fromTableRegex.FindStringSubmatch(sql)
	if len(matches) < 2 {
		return ""
	}
	tablePart := strings.Split(matches[1], " ")[0]
	return strings.Trim(tablePart, "`'\"")
}

需要注意,日志的几行需要根据项目所用日志进行变动

目前front-go当中已经封装好了logrus的方法

2. application.yml 增加配置
#SQL校验拦截器相关配置
sqlValidator:
  # 校验开关
  enable: true
  # 允许全表查询的表名白名单(不区分大小写)
  allowList: ["vehicle_claim_push_repair_case"]
  # 最大允许的表关联数量,0表示不限制
  maxJoinCount: 0

3. main方法当中初始化校验拦截器
// 初始化数据库信息
db := database.Init(logger.GetLogger())
// Sql校验拦截器开关配置
if config.GetString("sqlValidator.enable") == constant.Flag.True {
    // 开启Sql校验拦截器
    // 获取白名单
    allowList := config.GetStringSlice("sqlValidator.allowList")
    // 获取最大允许的表关联数量
    maxJoinCount := config.GetInt("sqlValidator.maxJoinCount")
    db.Use(util.NewSQLValidatorPlugin(maxJoinCount, allowList))
}

在初始化数据库信息之后,从配置文件当中校验开关

如果开关开启,那么从配置文件获取白名单和最大允许的表关联数量

然后初始化校验拦截器

实现原理

该 SQL 校验拦截器基于 GORM 插件机制,在 SQL 查询执行前对 SQL 语句进行多维度校验,以避免全表扫描和过多表关联带来的性能问题,其核心原理如下

1. 插件注册与执行时机

通过 GORM 的回调机制,将校验逻辑注册在 gorm:prepare_stmt 之后执行。此时 SQL 已初步构建完成,能获取到较为完整的 SQL 语句,确保校验的准确性。

2. SQL语句的完整获取

利用 GORM 自身的 Dialector.Explain 方法,获取最终要执行的、带参数的完整 SQL 语句,避免因手动构建 SQL 可能导致的语法错误等问题

3. 多维度校验

表关联数量校验

使用正则表达式 joinRegex 匹配 SQL 中的各类 JOIN 关键字(如 INNER JOINLEFT JOIN 等),统计表关联的数量。若关联数量超过配置的 maxJoinCount(且 maxJoinCount 大于 0),则判定为非法查询,阻止执行。

// 预编译正则
var (
    selectRegex            = regexp.MustCompile(`(?i)^SELECT`)
    whereRegex             = regexp.MustCompile(`(?i)\bWHERE\b`)
    limitOneRegex          = regexp.MustCompile(`(?i)LIMIT\s+1`)
    fromTableRegex         = regexp.MustCompile(`(?i)FROM\s+([^\s,]+)`)
    identityConditionRegex = regexp.MustCompile(`(?i)WHERE\s+(\d+)\s*=+\s*\\1\s*(AND|OR|$)`)
    isDeletedOnlyRegex     = regexp.MustCompile(`(?i)WHERE\s*([\w]+\.)?is_deleted\s*=`)
    hasLogicOpRegex        = regexp.MustCompile(`(?i)\bAND\b|\bOR\b`)
    joinRegex              = regexp.MustCompile(`(?i)\b(INNER|LEFT|RIGHT|FULL|CROSS)\s+JOIN\b|\bJOIN\b`)
)

基础查询规则校验

  • SELECT 语句判断:通过 selectRegex 判断是否为 SELECT 语句,非 SELECT 语句直接放行。
  • LIMIT 1 放行:若 SQL 中包含 LIMIT 1(由 limitOneRegex 匹配),说明是简单查询,直接放行。
  • WHERE 子句有效性校验
    • 若存在 WHERE 子句,但仅为恒等条件(如 1=1,由 identityConditionRegex 匹配),判定为无效查询。
    • 若存在 WHERE 子句,但仅包含逻辑删除字段 is_deleted 的条件且无其他有效筛选条件(由 isDeletedOnlyRegexhasLogicOpRegex 配合判断),也判定为无效查询。
    • 若不满足上述无效情况(即 WHERE 子句包含有效筛选条件),则放行。
// 2. 基础规则校验
    isSelect := selectRegex.MatchString(cleanSQL)
    hasWhere := whereRegex.MatchString(cleanSQL)
    hasLimitOne := limitOneRegex.MatchString(cleanSQL)
    isOnlyIdentity := hasWhere && identityConditionRegex.MatchString(cleanSQL)
    isOnlyIsDeleted := false
    if hasWhere && isDeletedOnlyRegex.MatchString(cleanSQL) {
        wherePart := p.extractWhereClause(cleanSQL)
        isOnlyIsDeleted = !hasLogicOpRegex.MatchString(wherePart)
    }

    if !isSelect || hasLimitOne || (!isOnlyIdentity && !isOnlyIsDeleted) {
        return nil
    }

白名单校验

提取 SQL 中的主表名(由 fromTableRegex 匹配并处理),若主表名在配置的白名单 allowList 中(不区分大小写),则放行该查询,允许全表扫描等操作。

// 3. 白名单校验
    tableName := extractMainTableName(cleanSQL)
    if tableName != "" && p.allowList[strings.ToLower(tableName)] {
		return nil
	}
4. 错误处理

若校验不通过,会记录错误日志(包含具体 SQL 语句),并通过 db.AddError 方法将错误绑定到 GORM 上下文,阻断后续 SQL 执行,防止低效查询对数据库性能造成影响

仓库地址

因为是Go应用,不需要依赖,直接把文件复制过去复用即可


网站公告

今日签到

点亮在社区的每一天
去签到