使用Golang模板拼sql(及校验)

1. 版权

本文为原创, 遵循 CC 4.0 BY-SA 版权协议, 转载需注明出处: https://blog.csdn.net/big_cheng/article/details/108422867.
文中代码属于 public domain (无版权).

2. 基本实现

在一个文件里使用Go模板语法, 一个模板定义一条sql, 如:

{{define "user_list"}}
select id,name,title from user
{{end}}

{{define "user_ins"}}
insert into user (name,title) values ("{{.name}}", "{{.title}}")
{{end}}

例如执行"user_ins"模板时传递包含"name"和"title"的map, 就得到最终sql.

在项目模块根目录(就是包含go.mod文件的目录) 创建"sqltmpl"子目录, 进入后创建"sql_tmpl.go"文件. 定义SqlTmpl类型:

package sqltmpl

import (
	"errors"
	"io/ioutil"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"text/template"
	"time"
	"unicode/utf8"
)

type SqlTmpl struct {
	root  string
	cache *sync.Map // relpath => *item
}

type item struct {
	t       *template.Template
	relpath string
	modTime time.Time
}

func New(root string) *SqlTmpl {
	return &SqlTmpl{root: root, cache: &sync.Map{}}
}

cache是sync.Map, 并发读/写安全. root是文件根目录. 加载其下指定路径(如"/sql/user.sql") 的模板(文件):

// 加载(并缓存)一个模板.
func (st *SqlTmpl) loadTmpl(relpath string) (*template.Template, error) {
	if v, ok := st.cache.Load(relpath); ok {
		if item := v.(*item); st.isCacheItemValid(item) {
			return item.t, nil
		}
	}

	// load
	fullpath := filepath.Join(st.root, relpath)
	fi, err := os.Stat(fullpath)
	if err != nil {
		return nil, err
	}
	buf, err := ioutil.ReadFile(fullpath)
	if err != nil {
		return nil, err
	}

	t, err := template.New(relpath).Funcs(funcMap).Parse(string(buf))
	if err != nil {
		return nil, err
	}
	st.cache.Store(relpath, &item{t, relpath, fi.ModTime()})
	return t, nil
}

func (st *SqlTmpl) isCacheItemValid(item *item) bool {
	if fi, err := os.Stat(filepath.Join(st.root, item.relpath)); err != nil {
		return false //(如果文件不存在, 不报错)
	} else {
		return fi.ModTime().Equal(item.modTime)
	}
}

如果加载时cache里已经有 并且文件修改时间未变, 则使用缓存.

执行方法就是先加载模板(文件), 再执行其中指定的模板:

var dot_out_key = "_out"

// 执行一个sql模板. 参数ident 如"/sql/user#user_list".
// 返回: sql + 实参args, 或错误.
func (st *SqlTmpl) Exec(ident string, dot map[string]interface{}) (string, []interface{}, error) {
	i := strings.LastIndex(ident, "#")
	if i == -1 {
		return "", nil, errors.New("ident格式错误")
	}
	relpath, name := ident[:i], ident[i+1:]
	if !strings.HasSuffix(relpath, ".sql") {
		relpath += ".sql"
	}

	t, err := st.loadTmpl(relpath)
	if err != nil {
		return "", nil, err
	}

	// exec
	sb := &strings.Builder{}
	dot[dot_out_key] = make([]interface{}, 0)
	defer delete(dot, dot_out_key)
	if err = t.ExecuteTemplate(sb, name, dot); err != nil {
		return "", nil, err
	}

	ql := sb.String()
	args := dot[dot_out_key].([]interface{})
	return ql, args, nil
}

上面首先为了简化, “/sql/user.sql” + “user_list” 可以合并传一个参数"/sql/user#user_list". 其次dot里保留"_out"项 用来存储输出实参值(后叙).

测试:

func main() {
	st := sqltmpl.New("d:/")

	dot := map[string]interface{}{
		"name":  "tom",
		"title": "engineer",
	}
	if ql, args, err := st.Exec("/test#user_ins", dot); err != nil {
		panic(err)
	} else {
		fmt.Printf("%s%v", ql, args)
	}
}

结果:


insert into user (name,title) values ("tom", "engineer")
[]

可见dot里的"name"和"title"实参值已经替换到最终sql.

3. push_arg

为了escape和安全原因, 最终sql里都会用"?" 代表动态参数, 执行sql时再提供动态参数的实际值. 如此定义一个push_arg函数:

var funcMap = template.FuncMap{
	"push_arg": push_arg,
}

// {{push_arg . .xx}}
func push_arg(dot map[string]interface{}, arg interface{}) string {
	arr := dot[dot_out_key].([]interface{})
	dot[dot_out_key] = append(arr, arg)
	return "?"
}

它在执行时将实参值push到前述的dot保留项"_out" 而返回"?". 修改模板定义:

{{define "user_ins"}}
insert into user (name,title) values ("{{.name | push_arg .}}", "{{.title | push_arg .}}")
{{end}}

dot.name 通过Go模板的管道符号"|" 成为push_arg的末参即: {{push_arg . .name}} - “.” 对应push_arg函数的dot参数, “.name” 对应arg参数.
再次执行测试:


insert into user (name,title) values (?, ?)
[tom engineer]

push_arg返回的"?" 替换到sql, 实参值正确搜集到.

4. v_text 文本校验

实践中发现正好可以提供参数校验功能, 可以大幅简化业务代码. 例如校验文本的长度:

{{define "user_ins"}}
insert into user (
    name,title
) values (
    {{v_text .name "姓名" "max" 5 | push_arg .}}
    ,{{v_text .title "头衔" "max" 5 | push_arg .}}
)
{{end}}

如果实参值超过上面指定的长度(以及类型错误), 执行时将报错.

实现v_text 函数:

var funcMap = template.FuncMap{
	"push_arg": push_arg,
	"v_text":   v_text,
}

// text校验如 {{v_text .title "*标题" "max" 16 "min" 10}}
//     名称(参数2) 以星号开头=必填(len>=1)
// 	   max: 最大长度(填了时)
//     min: 最小长度(填了时)
func v_text(value interface{}, name string, specs ...interface{}) (interface{}, error) {
	req := false
	if name[:1] == "*" {
		req = true
		name = name[1:]
	}

	if value == nil {
		if req {
			return nil, errors.New("错误: '" + name + "'值 缺")
		}
		return nil, nil
	}

	str, ok := value.(string)
	if !ok {
		return nil, errors.New("错误: '" + name + "'值 类型不对")
	}

	strlen := utf8.RuneCountInString(str)
	if req && strlen == 0 {
		return nil, errors.New("错误: '" + name + "'值 缺")
	}

	if strlen >= 1 {
		for i, LEN := 0, len(specs); i < LEN; i++ {
			switch specs[i] {
			case "max":
				if strlen > iof(specs[i+1]) {
					return nil, errors.New("错误: '" + name + "'值 过长")
				}
				i++
			case "min":
				if strlen < iof(specs[i+1]) {
					return nil, errors.New("错误: '" + name + "'值 过短")
				}
				i++
			default:
				return nil, errors.New("unknown spec for '" + name + "'")
			}
		}
	}

	return str, nil
}

// int、或int64强转为int. 否则panic (包括v nil).
func iof(v interface{}) int {
	if i, ok := v.(int); ok {
		return i
	}
	return int(v.(int64))
}

这里实现的规则是:

  • 必填 (name以星号开头): value 必须非nil, 是string类型, 且字符长度>=1
  • 非必填 (name不以星号开头): 可以不传 (即value nil, 也即dot 里没有该项 或项值为nil), 也可以传empty 字符串"". 非nil 必须是string类型
  • 仅填了(string且非empty) 时才做后面的max/min校验
  • v_text成功, 要么返回nil 要么返回string

测试:


panic: template: /test.sql:13:7: executing "user_ins" at <v_text .title "头衔" "max" 5>: error calling v_text: 错误: '头衔'值 过长

Go模板的报错详细到文件/行号/模板名/函数/函数错误. 用户只需看最末部分.
title实参值"engineer" 长度超过5. 将模板里max改成16, 再测试无错.

测试title 必填而不传:

,{{v_text .title "*头衔" "max" 16 | push_arg .}}

--------

	dot := map[string]interface{}{
		"name":   "tom",
		"title2": "engineer",
	}

--------
错误: '头衔'值 缺

改传nil 也一样:

,{{v_text .title "*头衔" "max" 16 | push_arg .}}

--------

	dot := map[string]interface{}{
		"name":  "tom",
		"title": nil,
	}

--------
错误: '头衔'值 缺

关于string null/empty 的校验规则, 每个项目的需求可能都不一样 (不同数据库的规则也不一样, 例如Oracle empty即null). 见后续相关讨论. 建议各项目定制.

5. p_status例子、is_nil

假设user.status 0=正常1=禁用. 查询页面有个checkbox 当勾选时传"1" 只查禁用user, 不勾时不传都查. 由于未来可能扩展其他状态, 所以模板需明确限制仅在传"1" 时才过滤:

{{define "user_list"}}
select id,name,title
from user
where
    1=1 {{if eq .p_status "1"}} ,and status=1 {{end}}
{{end}}

测试:

dot := map[string]interface{}{"p_status": "1",}
--------
    1=1  ,and status=1

dot := map[string]interface{}{"p_status": "2"}
--------
    1=1

dot := map[string]interface{}{"p_status2": "2"}
--------
panic: ...... incompatible types for comparison

当不传参数时, eq会出错 (模板里nil和string 不能比较).

改成 传了且传了"1":


    1=1 {{if and .p_status (eq .p_status "1")}} ,and status=1 {{end}}
    

结果仍一样, 因为模板里的and 实际是函数而非Go语言里的&&, 函数的每个参数都预先求值所以没有短路特性.

正确写法使用嵌套:


    1=1 {{if .p_status}} {{if eq .p_status "1"}} ,and status=1 {{end}} {{end}}
    

但这里有个潜在的陷阱: 如果p_status 改成int 类型 (http提交的参数都是文本类型, 但在业务层可以手工加参数, 以及可能通过非http方式调用). 而模板里int 0 是false:

    1=1 {{if .p_status}} {{if eq .p_status 0}} ,and status=0 {{end}} {{end}}
--------
dot := map[string]interface{}{"p_status": 0}
--------
    1=1

当需要细微区分empty string|0 与nil 时, 可使用is_nil 函数:

var funcMap = template.FuncMap{
	"is_nil":   is_nil,
	"push_arg": push_arg,
	"v_text":   v_text,
}

// {{if is_nil .xx}}
func is_nil(value interface{}) bool {
	return value == nil
}

    1=1 {{if not (is_nil .p_status)}} {{if eq .p_status 0}} ,and status=0 {{end}} {{end}}
    

6. v_int 整数校验

var funcMap = template.FuncMap{
	"is_nil":   is_nil,
	"push_arg": push_arg,
	"v_int":    v_int,
	"v_text":   v_text,
}

// int64校验如 {{v_int .type "类型" "max" 16 "min" 10 "in" ",0,1,2,"}}
//     名称(参数2) 以星号开头=必填
//     max: 最大值(填了时)
//     min: 最小值(填了时)
//     in: 在指定字符串里(填了时) (注-spec格式: 仅含数字和逗号,前后要有逗号. 目前未做严格校验)
func v_int(value interface{}, name string, specs ...interface{}) (interface{}, error) {
	req := false
	if name[:1] == "*" {
		req = true
		name = name[1:]
	}

	if value == nil {
		if req {
			return nil, errors.New("错误: '" + name + "'值 缺")
		}
		return nil, nil
	}

	// 支持int,int64,string
	var i64 int64
	var i int
	var str string
	var err error
	var ok bool
	if i, ok = value.(int); ok {
		i64 = int64(i)
	}
	if !ok {
		i64, ok = value.(int64)
	}
	if !ok {
		if str, ok = value.(string); ok {
			if i64, err = strconv.ParseInt(str, 10, 64); err != nil {
				return nil, errors.New("错误: '" + name + "'值 不能解析为整数")
			}
		} else {
			return nil, errors.New("错误: '" + name + "'值 类型不对")
		}
	}

	for i, LEN := 0, len(specs); i < LEN; i++ {
		switch specs[i] {
		case "max":
			if i64 > i64of(specs[i+1]) {
				return nil, errors.New("错误: '" + name + "'值 过大")
			}
			i++
		case "min":
			if i64 < i64of(specs[i+1]) {
				return nil, errors.New("错误: '" + name + "'值 过小")
			}
			i++
		case "in": // "in" ",0,1,2,"
			si := strconv.FormatInt(i64, 10)
			if !strings.Contains(specs[i+1].(string), ","+si+",") {
				return nil, errors.New("错误: '" + name + "'值 不允许的值")
			}
			i++
		default:
			return nil, errors.New("unknown spec for '" + name + "'")
		}
	}

	return i64, nil
}

// int64、或int强转为int64. 否则panic (包括v nil).
func i64of(v interface{}) int64 {
	if i, ok := v.(int); ok {
		return int64(i)
	}
	return v.(int64)
}
  • 必填处理同v_text
  • 支持传string 自动转成int64 (因为http提交的参数都是文本类型). 但未实现empty => 0 (按项目定制)
  • 增加"in" spec
  • v_int成功, 要么返回nil 要么返回int64

7. 其他

noprint

仅校验而不输出时可使用.

func noprint(any interface{}) string {
	return ""
}

分页

由sql语句本身应该可以处理:
original: select … from … where … order by …
=>
total: select count(1) from … where …
paging: original + “limit offset”

关于trim

可以在http参数组装阶段trim, 这样拼sql时就不用考虑.

8. 例子

传了p_status条件时:

    {{if not (is_nil .p_status)}}
        and status = {{v_int .p_status "*p_status" "in" ",0,1," | push_arg .}}
    {{end}}

LIKE条件:

    {{if .p_name}}
        and name LIKE {{print "%" (v_text .p_name "*p_name") "%" | push_arg .}}
    {{end}}

Sql函数的参数:

insert into user ( ..., password, ...)
values (
    ...
    ,MD5( {{v_text .password "*password" "min" 8 "max" 32 | push_arg .}} )
    ...
)

校验重名 (.isEdit 在修改用户时手工设置):

select 1
from user
where
    name = {{v_text .name "*姓名" "max" 32 | push_arg .}}
    {{if .isEdit}} and id != {{v_int .id "*id" | push_arg .}} {{end}}
limit 1

int值可以直接输出到sql里 (但是建议总是使用push_arg, 以免该使用时忘记了导致sql注入):


where id = {{v_int .id "*id"}}

动态CASE (使用循环; when/then 均?):

update user
set
    title = case id {{range $i, $id := .ids}}when {{v_int $id "*id" | push_arg $}} then {{v_text (index $.new_titles $i) "*new_title" "max" 32 | push_arg $}} {{end}}
        else title end
where
    id in ( null {{range .ids}},{{v_int . "*id" | push_arg $}}{{end}} )

如上在数量不多时, 可以使用case一次性更新多组id+title. 注: 由于range里"." 代表循环当前值($id), 所以push_arg 实参改用$.

批量更新还可以使用join update的方式, 例如(v_arr是数组校验):

{{$ids := v_arr .ids "*用户id数组" "max" 20}}
{{$ages := v_arr .ages "*年龄数组" "max" 20}}

update user u
    join ( {{range $i, $v := $ids}}
        {{if eq $i 0}}
        select {{v_int . "*用户id" | push_arg $}} 'id', {{v_int (index $ages $i) "age" | push_arg $}} 'age'
        {{else}}
        union all select {{v_int . "*用户id" | push_arg $}}, {{v_int (index $ages $i) "age" | push_arg $}}
        {{end}}
    {{end}} ) _t on u.id = _t.id
set u.age = _t.age
where ...

数据量更大时可以使用临时表方式: 将数据批量插入临时表后再处理.

如果2张表结构相似, 可以共享模板, 通过传参来选表:

{{define "_tab"}}{{v_text ._tab "*_tab" "in" ",n,p," | noprint}}{{if eq ._tab "n"}}news{{else}}pub{{end}}{{end}}

{{define "_list"}}
select ...
from {{template "_tab" .}}
where ...
{{end}}

或者同一组字段出现在多张表里时, 也可以共享校验规则:

{{define "_v_age"}}{{v_int .age "*年龄" "min" 18 | push_arg .}}{{end}}

{{define "user_upd"}}
update ...
set age = {{template "_v_age" .}}
where ...
{{end}}

9. 讨论

表的简化设计:

  • 整数统一用BIGINT
  • 文本字段尽量NOT NULL
  • 整数字段(非外键时) 也可用0 代替NULL

通常文本字段不存在一定要区分null和empty的场景. 例如假如dept.id 是文本编码, dept.parent 没有父部门时可以存empty 而非null. 除非使用了数据库FOREIGN KEY 时只能按数据库的规则来设计.

项目也可定制其他校验spec (如v_text 的"v_pwd|v_email|v_mobile"), 可以设计为搭配使用例如:


{{v_text .email "*邮箱" "max" 64 | v_email | push_arg .}}

10. 参考

GolangPkg text/template | 笔记

一个Golang模板的include设计

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值