本文为原创, 遵循 CC 4.0 BY-SA 版权协议, 转载需注明出处: https://blog.csdn.net/big_cheng/article/details/108422867.
文中代码属于 public domain (无版权).
在一个文件里使用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.
为了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, 实参值正确搜集到.
实践中发现正好可以提供参数校验功能, 可以大幅简化业务代码. 例如校验文本的长度:
{{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))
}
这里实现的规则是:
测试:
panic: template: /test.sql:13:7: executing "user_ins" at : 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). 见后续相关讨论. 建议各项目定制.
假设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}}
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)
}
仅校验而不输出时可使用.
func noprint(any interface{}) string {
return ""
}
由sql语句本身应该可以处理:
original: select … from … where … order by …
=>
total: select count(1) from … where …
paging: original + “limit offset”
可以在http参数组装阶段trim, 这样拼sql时就不用考虑.
传了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}}
表的简化设计:
通常文本字段不存在一定要区分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 .}}
GolangPkg text/template | 笔记
一个Golang模板的include设计