28 KiB
u-tpl
高性能 Go SQL 模板引擎 — 为现代 SQL 模板管理设计,AI 友好。
u-tpl 不是 ORM,不是查询构建器。它只做一件事:把模板渲染成安全的参数化 SQL。
模板字符串 → u-tpl → SQL + Args → database/sql
不推荐:Go 代码里拼 SQL 字符串(注入风险、难维护、无法分离参数)。
特性
- 安全参数化 —
#{param}自动生成占位符 + 收集参数,杜绝 SQL 注入 - Go 风格控制流 —
@if(){}/@for()/else{}块结构,开发者零学习成本 - 受控的原样替换 —
${param}用于表名/列名,可配置白名单拦截非法值 - 多数据库占位符 — 同一模板适配
?(MySQL) /$1(PostgreSQL) /:1(Oracle) - 零依赖 — 纯 Go 标准库实现,无第三方依赖
- AI 友好 —
#{}/${}借鉴 MyBatis,@if/@for借鉴 Go,LLM 训练数据覆盖率极高 - 高性能 — 解析一次 AST,执行只需树遍历,~500ns/op(简单模板)
快速开始
package main
import (
"fmt"
"github.com/nicedoc/utpl"
)
func main() {
engine := utpl.New()
tpl := engine.MustParse("user_by_id",
"SELECT id, name, email FROM users WHERE id = #{id} AND status = #{status}")
result, _ := tpl.Execute(map[string]any{
"id": 42,
"status": "active",
})
fmt.Println(result.SQL) // SELECT id, name, email FROM users WHERE id = ? AND status = ?
fmt.Println(result.Args) // [42 active] (示意输出)
// 直接用于 database/sql:
// rows, err := db.Query(result.SQL, result.Args...)
}
安装
go get github.com/nicedoc/utpl
语法参考
概览
| 语法 | 用途 | 替换为 | Args |
|---|---|---|---|
#{param} |
安全参数 | ? (或 $1) |
收集值 |
${param} |
原样替换 | 值本身 | 不收集 |
@if(expr) { } else { } |
条件判断/分支 | — | — |
@for(v, range list) { } |
循环 | — | 每项收集 |
@tpl("x") { } |
命名语句块 | — | — |
@include("path") |
引入子模板 | 子模板内容 | 共享父模板变量 |
@namespace("x") |
命名空间声明 | — | — |
# 注释 |
注释 | 空 | — |
#后跟{是安全参数(#{param}),否则是行注释(# 这是注释)。
语法设计遵循 Go 惯例:@ 前缀标记模板指令,# 前缀标记参数和注释,{ } 块结构与 Go 一致。
#{param} — 安全参数
最常用的语法。值通过数据库驱动参数化传递,不会被拼入 SQL 字符串。
-- 模板
SELECT * FROM users WHERE id = #{user_id}
-- 输入: {"user_id": 42}
-- SQL: SELECT * FROM users WHERE id = ?
-- Args: [42]
-- 多参数
SELECT * FROM orders
WHERE user_id = #{uid} AND status = #{status} AND amount > #{min_amount}
-- 输入: {"uid": 1, "status": "paid", "min_amount": 100.0}
-- SQL: SELECT * FROM orders WHERE user_id = ? AND status = ? AND amount > ?
-- Args: [1, paid, 100]
适用场景:所有 WHERE 条件值、LIMIT/OFFSET、INSERT/UPDATE 的值。
${param} — 原样替换
值直接拼入 SQL 字符串,不经过参数化。适用于无法参数化的场景(表名、列名、SQL 关键字)。
-- 模板
SELECT ${columns} FROM ${table} WHERE tenant_id = #{tid}
-- 输入: {"columns": "id, name", "table": "users", "tid": 100}
-- SQL: SELECT id, name FROM users WHERE tenant_id = ?
-- Args: [100]
${}存在注入风险,建议配合 安全策略 使用白名单。
@if(expr) { } — 条件判断
根据条件决定是否拼接 SQL 片段。块结构与 Go 的 if 一致,支持多行和行内写法。
-- 可选 WHERE 条件
SELECT * FROM users WHERE 1=1
@if(name != "") {
AND name LIKE #{name}
}
@if(status != nil) {
AND status = #{status}
}
@if(min_age != nil && max_age != nil) {
AND age BETWEEN #{min_age} AND #{max_age}
}
// 场景 1: 只传 name
tpl.Execute(map[string]any{"name": "%张%"})
// SQL: SELECT * FROM users WHERE 1=1 AND name LIKE ?
// Args: ["%张%"]
// 场景 2: 传 name + status
tpl.Execute(map[string]any{"name": "%张%", "status": "active"})
// SQL: SELECT * FROM users WHERE 1=1 AND name LIKE ? AND status = ?
// Args: ["%张%", "active"]
// 场景 3: 全条件
tpl.Execute(map[string]any{"name": "", "status": "active", "min_age": 18, "max_age": 60})
// SQL: SELECT * FROM users WHERE 1=1 AND status = ? AND age BETWEEN ? AND ?
// Args: ["active", 18, 60]
else 分支(支持行内写法):
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
多条件分支(else if 链,同行分隔):
@if(role == "admin") {
AND level >= #{admin_level}
} else if (role == "manager") {
AND level >= #{mgr_level}
} else {
AND level >= 1
}
@for(v, range list) { } — 循环
遍历集合,生成重复 SQL 片段。range 关键字遵循 Go 语法。
-- 动态 IN 子句(尾部逗号自动修剪)
SELECT * FROM products
WHERE category_id IN (@for(id, range ids) {#{id}, })
-- 输入: {"ids": []int{1, 5, 12, 27}}
-- SQL: SELECT * FROM products WHERE category_id IN (?, ?, ?, ?)
-- Args: [1, 5, 12, 27]
@for和@tpl块输出末尾的逗号自动修剪(去除尾部空白后,最后一个字符是,则移除)。 如需自定义分隔逻辑,可用@for(i, v, range items) { ... }配合loop_index/loop_last变量(i是索引,v是值,与 Gorange一致)。
循环内置变量:
| 变量 | 类型 | 说明 |
|---|---|---|
loop_index |
int | 当前索引(从 0 开始) |
loop_first |
bool | 是否是第一个元素 |
loop_last |
bool | 是否是最后一个元素 |
# 注释 — 注释
行注释,# 之后到行末的内容在渲染时被忽略。
SELECT * FROM users
# 只查活跃用户,排除测试账号
WHERE status = #{status} AND is_test = 0
表达式语言
条件表达式保持极简,覆盖 SQL 模板的实际需求:
变量访问 name user.name
nil 检查 name != nil status == nil
字符串比较 name != "" role == "admin"
数值比较 age >= 18 count < 100
逻辑运算 a != nil && b != "" a || b !flag
内置函数 len(ids) > 0 len(items) == 0
@tpl("x") { } — 命名语句块
一个模板文件可以包含多个命名语句块,执行时按名称取用。
-- tpl/orders.tpl
@tpl("search") {
SELECT o.id, o.user_name, o.total_amount
FROM orders o
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
LIMIT #{limit}
}
@tpl("count") {
SELECT COUNT(*) FROM orders
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
}
@tpl("insert") {
INSERT INTO orders (user_name, total_amount, tenant_id)
VALUES (#{user_name}, #{total_amount}, #{tenant_id})
}
tpl := engine.MustParse("orders", ordersTPL)
searchResult, _ := tpl.ExecuteBlock("search", map[string]any{
"tenant_id": 100, "user_name": "%张%", "limit": 20,
})
insertResult, _ := tpl.ExecuteBlock("insert", map[string]any{
"user_name": "张三", "total_amount": 99.9, "tenant_id": 100,
})
searchResult.SQL // SELECT o.id, ... LIMIT ?
searchResult.Args // [100, %张%, 20]
文件内要么全用
@tpl块,要么不用,不能混用。有@tpl块时,顶级非指令文本返回ParseError。 有@tpl块时,用ExecuteBlock("name", vars)按名取用;无@tpl块时,整个模板作为默认语句,用Execute()执行。 块名在文件内唯一,重复时返回ParseError。 多个@tpl块间有重复片段时,用@include提取公共部分(见下一节)。
@include("path") — 模块引入
在当前位置插入另一个模板文件的内容,复用公共片段。
tpl/
├── common/
│ ├── tenant.tpl # 租户过滤
│ └── pagination.tpl # 分页
├── orders.tpl
└── users.tpl
-- tpl/common/tenant.tpl
AND tenant_id = #{tenant_id}
@if(department_id != nil) { AND department_id = #{department_id} }
-- tpl/common/pagination.tpl
LIMIT #{limit} OFFSET #{offset}
-- tpl/orders.tpl
SELECT o.id, o.user_name, o.total_amount
FROM orders o
WHERE 1=1
@include("common/tenant")
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
ORDER BY o.created_at DESC
@include("common/pagination")
// 配置引入解析器 — 挂在 Engine 上,所有模板共享
engine := utpl.New(utpl.WithIncludeResolver(func(path string) (string, error) {
src, err := os.ReadFile("tpl/" + path + ".tpl")
return string(src), err
}))
// 之后所有 Parse 的模板都支持 @include
tpl := engine.MustParse("orders", ordersTPL)
result, _ := tpl.Execute(map[string]any{"tenant_id": 100, "limit": 20, "offset": 0})
规则:
@include在解析期展开,子模板与父模板共享变量,子模板中的#{}会追加到同一个 Args 列表- 子模板只能包含纯 SQL 片段 + 控制流,不能包含
@tpl块和@namespace声明 - 引入路径不含后缀,解析器自动查找
.tpl/.sql - 不配置
WithIncludeResolver时,遇到@include返回ParseError - 不支持循环引入,检测到时返回
ParseError
@namespace("name") — 命名空间
当多个文件存在同名 @tpl 块时(如每个模块都有 search / insert),用 @namespace 显式声明命名空间来区分。
-- tpl/orders.tpl
@namespace("orders")
@tpl("search") {
SELECT * FROM orders WHERE 1=1
@include("common/tenant")
LIMIT #{limit}
}
@tpl("insert") {
INSERT INTO orders (user_name, tenant_id) VALUES (#{user_name}, #{tenant_id})
}
-- tpl/users.tpl
@namespace("users")
@tpl("search") {
SELECT * FROM users WHERE 1=1
@include("common/tenant")
LIMIT #{limit}
}
@tpl("insert") {
INSERT INTO users (name, email) VALUES (#{name}, #{email})
}
tplOrders := engine.MustParse("orders", ordersTPL)
tplUsers := engine.MustParse("users", usersTPL)
// 跨命名空间 — 必须用 命名空间.块名
tplOrders.ExecuteBlock("orders.search", vars)
tplUsers.ExecuteBlock("users.search", vars)
// 同命名空间内 — 块名可省略前缀
tplOrders.ExecuteBlock("search", vars) // 等同于 "orders.search"
// 注意:短名只在同一个 Template 对象上有效,跨 Template 对象必须用全限定名
规则:
@namespace声明在文件顶部,可选,不写则无命名空间- 命名空间是显式声明,不依赖文件路径或目录结构
- 同命名空间内短名(
"search")和全限定名("orders.search")等价,但短名只在同一个 Template 对象上有效 - 移动文件、重命名目录不影响模板引用
实战示例
条件搜索 + 排序 + 分页
-- 模板: order_search
SELECT o.id, o.user_name, o.total_amount, o.created_at
FROM orders o
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") {
AND o.user_name LIKE #{user_name}
}
@if(status != nil) {
AND o.status = #{status}
}
@if(min_amount != nil) {
AND o.total_amount >= #{min_amount}
}
@if(created_after != nil) {
AND o.created_at >= #{created_after}
}
@if(order_ids != nil && len(order_ids) > 0) {
AND o.id IN (@for(oid, range order_ids) {#{oid}, })
}
ORDER BY ${sort_col} @if(sort_desc) {DESC} else {ASC}
LIMIT #{limit} OFFSET #{offset}
result, _ := tpl.Execute(map[string]any{
"tenant_id": 100,
"user_name": "%张%",
"status": "paid",
"min_amount": nil,
"created_after": nil,
"order_ids": []int64{2001, 2002},
"sort_col": "o.total_amount",
"sort_desc": true,
"limit": 20,
"offset": 0,
})
// SQL: SELECT o.id, o.user_name, o.total_amount, o.created_at
// FROM orders o
// WHERE o.tenant_id = ? AND o.user_name LIKE ? AND o.status = ?
// AND o.id IN (?, ?)
// ORDER BY o.total_amount DESC
// LIMIT ? OFFSET ?
// Args: [100, %张%, paid, 2001, 2002, 20, 0]
批量 INSERT
-- 模板: batch_insert
INSERT INTO users (name, email, status) VALUES
@for(u, range users) {
(#{u.name}, #{u.email}, #{u.status}),
}
type User struct {
Name string
Email string
Status string
}
result, _ := tpl.Execute(map[string]any{
"users": []User{
{"张三", "zhangsan@example.com", "active"},
{"李四", "lisi@example.com", "active"},
},
})
// SQL: INSERT INTO users (name, email, status) VALUES (?, ?, ?), (?, ?, ?)
// Args: [张三, zhangsan@example.com, active, 李四, lisi@example.com, active]
动态 UPDATE
-- 模板: update_user
UPDATE users SET
@if(name != "") { name = #{name}, } # 字符串: 空字符串表示未传
@if(email != "") { email = #{email}, } # 字符串: 空字符串表示未传
@if(status != nil) { status = #{status}, } # 可空字段: nil 表示未传
updated_at = NOW()
WHERE id = #{id} AND tenant_id = #{tenant_id}
// 只更新 status
tpl.Execute(map[string]any{
"name": "",
"email": "",
"status": "inactive",
"id": 42,
"tenant_id": 100,
})
// SQL: UPDATE users SET status = ?, updated_at = NOW() WHERE id = ? AND tenant_id = ?
// Args: [inactive, 42, 100]
跨数据库兼容
// MySQL
engine := utpl.New(utpl.WithPlaceholderStyle(utpl.QuestionMark))
// 同一模板,输出: WHERE id = ? AND name = ?
// PostgreSQL
engine := utpl.New(utpl.WithPlaceholderStyle(utpl.DollarNumber))
// 同一模板,输出: WHERE id = $1 AND name = $2
最佳实践
1. 安全第一:默认 #{},谨慎 ${}
-- 好 — 值走参数化,不可能注入
WHERE id = #{user_id} AND status = #{status}
-- 差 — 值直接拼入 SQL
WHERE id = ${user_id} AND status = ${status}
原则:能用 #{} 就用 #{}。只有表名、列名、SQL 关键字等无法参数化的场景才用 ${}。
2. ${} 必须配白名单
生产环境始终为 ${} 配置安全策略,防止注入。
engine := utpl.New(
utpl.WithRawPolicy(utpl.RawAllowlist(map[string][]string{
"table_name": {"users", "products", "orders"},
"sort_col": {"id", "name", "created_at", "amount"},
})),
)
不要用黑名单——攻击者总能找到你没想到的列名。
3. 排序方向用条件分支,不要参数化
ASC/DESC 是 SQL 关键字,多数数据库不支持参数化排序方向。
-- 好 — 关键字写在模板里
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
-- 差 — 多数数据库不会正确处理参数化的 ASC/DESC
ORDER BY ${sort_col} #{direction}
4. WHERE 条件用 1=1 起手 + @if 追加
避免动态拼接 WHERE 关键字本身,减少模板复杂度。
-- 好 — 1=1 起手,每个条件独立判断
SELECT * FROM users WHERE 1=1
@if(name != "") { AND name LIKE #{name} }
@if(status != nil) { AND status = #{status} }
@if(min_age != nil) { AND age >= #{min_age} }
-- 差 — 需要处理 AND 的前缀问题
SELECT * FROM users
@if(name != "") { WHERE name LIKE #{name} }
@if(status != nil) { AND status = #{status} }
5. IN 子句空值防护
始终检查列表非空再拼接 IN 子句,避免生成无效 SQL。
-- 好 — 空列表时整个 IN 条件不出现
@if(ids != nil && len(ids) > 0) {
AND id IN (@for(id, range ids) {#{id}, })
}
@for对 nil 和空集合均不输出任何内容,不报错。但仍建议在@if中做前置检查,避免生成IN ( )语法。
// 传入 nil 或空切片 → IN 子句不会出现
tpl.Execute(map[string]any{"ids": nil})
// SQL: SELECT * FROM users WHERE 1=1
6. 模板启动时解析,不要每次请求 Parse
Parse 有开销(~10μs),应在服务启动时完成,Template 对象可并发安全使用。
// 好 — 启动时解析,存入 map 复用
var (
engine = utpl.New()
tplSearch = engine.MustParse("order_search", orderSearchSQL)
tplInsert = engine.MustParse("batch_insert", batchInsertSQL)
)
func SearchOrders(ctx context.Context, params map[string]any) (*utpl.Result, error) {
return tplSearch.Execute(params)
}
// 差 — 每次请求都 Parse
func SearchOrders(params map[string]any) (*utpl.Result, error) {
tpl, _ := engine.Parse("order_search", orderSearchSQL) // 每次都解析
return tpl.Execute(params)
}
7. LIMIT 始终参数化,不要省略
无 LIMIT 的查询可能返回全表数据,造成性能问题和内存溢出。
-- 好 — LIMIT 参数化,调用方控制
SELECT * FROM users WHERE status = #{status} LIMIT #{limit}
// 代码层提供默认值
if _, ok := params["limit"]; !ok {
params["limit"] = 100
}
8. 注释标注业务意图
模板注释帮助后续维护者(包括 AI)理解 SQL 的业务目的。
-- 模板: 活跃用户消费排行
SELECT m.name, SUM(o.amount) as total
FROM members m
JOIN orders o ON m.id = o.member_id
WHERE m.status = 'active'
# 时间范围由调用方传入,默认近30天
AND o.created_at >= #{start_time}
AND o.created_at < #{end_time}
GROUP BY m.id, m.name
ORDER BY total DESC
LIMIT #{limit}
9. 动态 UPDATE 用尾逗号模式
每个可选字段独立成块,末尾带逗号。最终 SQL 末尾的逗号由引擎自动修剪。
-- 好 — 尾逗号模式,字段增删不影响其他字段
UPDATE users SET
@if(name != "") { name = #{name}, }
@if(email != "") { email = #{email}, }
@if(status != nil) { status = #{status}, }
updated_at = NOW()
WHERE id = #{id}
10. 模板与业务代码分离
模板是配置,不是代码。不要在 Go 代码里拼 SQL 字符串。
方案一:Go embed(推荐)
模板放 .sql 文件,Go 编译时嵌入,部署零依赖。
project/
├── tpl/
│ ├── order_search.tpl
│ ├── order_insert.tpl
│ └── user_update.tpl
└── main.go
-- tpl/order_search.tpl
SELECT o.id, o.user_name, o.total_amount
FROM orders o
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
@if(status != nil) { AND o.status = #{status} }
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
LIMIT #{limit}
package main
import (
_ "embed"
"github.com/nicedoc/utpl"
)
//go:embed tpl/order_search.tpl
var orderSearchSQL string
//go:embed tpl/order_insert.tpl
var orderInsertSQL string
var (
engine = utpl.New()
tplSearch = engine.MustParse("order_search", orderSearchSQL)
tplInsert = engine.MustParse("order_insert", orderInsertSQL)
)
优点:编译时嵌入、无运行时 I/O、部署单二进制、IDE 支持语法高亮。
后缀:.tpl 和 .sql 均可,.tpl 强调模板属性,.sql 便于 IDE 直接识别 SQL 语法。
方案二:外部文件加载
模板放文件系统,支持运行时修改,适合需要热更新的场景。
func LoadTemplates(dir string) (map[string]*utpl.Template, error) {
engine := utpl.New()
tpls := make(map[string]*utpl.Template)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := filepath.Ext(name)
if ext != ".tpl" && ext != ".sql" {
continue
}
src, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return nil, err
}
key := strings.TrimSuffix(name, ext)
tpls[key] = engine.MustParse(key, string(src))
}
return tpls, nil
}
tpl/
├── order_search.tpl → templates["order_search"]
├── order_insert.sql → templates["order_insert"]
└── user_update.tpl → templates["user_update"]
方案三:集中注册 + 命名空间
模板多时,用注册函数统一管理。配合 @namespace + @tpl,一个文件管理一个领域。
var templates map[string]*utpl.Template
func initTemplates() {
engine := utpl.New(utpl.WithIncludeResolver(loadFile))
templates = map[string]*utpl.Template{
"orders": engine.MustParse("orders", loadDir("tpl/orders.tpl")),
"users": engine.MustParse("users", loadDir("tpl/users.tpl")),
"products": engine.MustParse("products", loadDir("tpl/products.tpl")),
}
}
// 使用 — 命名空间.块名
templates["orders"].ExecuteBlock("orders.search", params)
templates["orders"].ExecuteBlock("orders.insert", params)
templates["users"].ExecuteBlock("users.search", params)
-- tpl/orders.tpl
@namespace("orders")
@tpl("search") {
SELECT * FROM orders WHERE 1=1 @include("common/tenant") LIMIT #{limit}
}
@tpl("insert") {
INSERT INTO orders (user_name, tenant_id) VALUES (#{user_name}, #{tenant_id})
}
反模式
// 差 — Go 代码里拼 SQL
sql := "SELECT * FROM orders WHERE 1=1"
if name != "" {
sql += " AND name LIKE '" + name + "'" // 注入风险
}
// 差 — 模板散落在各个 handler 里
func OrderHandler() {
tpl, _ := utpl.New().Parse("tmp", "SELECT ...") // 每次请求都解析
}
API 参考
创建引擎
engine := utpl.New() // 默认: ? 占位符
engine := utpl.New( // 自定义配置
utpl.WithPlaceholderStyle(utpl.DollarNumber),
utpl.WithRawPolicy(utpl.RawAllowlist(...)),
)
选项:
| 选项 | 说明 | 默认值 |
|---|---|---|
WithPlaceholderStyle(style) |
占位符格式 | QuestionMark (?) |
WithRawPolicy(policy) |
${} 安全策略 |
无限制 |
WithIncludeResolver(fn) |
@include 路径解析器 |
无(遇到 @include 报错) |
WithStrictMode(bool) |
未定义变量时是否报错 | true(报错) |
占位符风格:
| 常量 | 输出 | 适用数据库 |
|---|---|---|
QuestionMark |
? |
MySQL, SQLite, SQL Server |
DollarNumber |
$1, $2, ... |
PostgreSQL |
ColonNumber |
:1, :2, ... |
Oracle(仅位置占位符,不支持命名参数 :name) |
解析模板
tpl, err := engine.Parse(name, source) // 返回错误
tpl := engine.MustParse(name, source) // 出错 panic
Template 对象是线程安全的,解析一次可并发使用。
执行模板
// 无命名块 — 整个模板作为一个语句
result, err := tpl.Execute(map[string]any{
"id": 42,
"status": "active",
})
result.SQL // "SELECT * FROM users WHERE id = ? AND status = ?"
result.Args // []any{42, "active"}
// 有命名块 — 按名称执行
result, err := tpl.ExecuteBlock("search", map[string]any{
"tenant_id": 100, "limit": 20,
})
// 仅获取 SQL 字符串(调试用途,无 Args 无法直接执行)
sql, err := tpl.ExecuteString(map[string]any{"id": 42})
sql, err := tpl.ExecuteBlockString("search", map[string]any{"limit": 20})
Result 结构
type Result struct {
SQL string // 渲染后的 SQL(含占位符)
Args []any // 参数值(按占位符顺序)
}
错误处理
result, err := tpl.Execute(vars)
if err != nil {
// ParseError — 模板语法错误,包含行号和列号
// ExecError — 执行时错误(如变量未定义)
// UnsafeRawError — ${} 值被安全策略拦截
}
变量未定义时默认返回
ExecError(fail-fast)。如需宽松模式(未定义变量输出空字符串),可用WithStrictMode(false)。
安全策略
${} 原样替换有注入风险。通过 RawPolicy 限制允许的值。
白名单模式(推荐)
engine := utpl.New(
utpl.WithRawPolicy(utpl.RawAllowlist(map[string][]string{
"table_name": {"users", "products", "orders"},
"sort_col": {"id", "name", "created_at", "amount"},
})),
)
// 合法 — 在白名单中
result, err := tpl.Execute(map[string]any{"table_name": "users"}) // OK
// 非法 — 不在白名单中
result, err := tpl.Execute(map[string]any{"table_name": "users; DROP TABLE users; --"})
// err = UnsafeRawError: raw substitution '${table_name}' value not in allowlist
黑名单模式
engine := utpl.New(
utpl.WithRawPolicy(utpl.RawBlocklist(map[string][]string{
"sort_col": {"password", "secret_key", "token"},
})),
)
不配置策略
不配置 RawPolicy 时,${} 直接替换,不做任何拦截。仅适用于内部可信场景。
架构
模板源码
│
▼
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Lexer │ → │ Parser │ → │ Template │ 解析期(一次性)
│ 词法分析 │ │ AST 构建 │ │ (AST) │
└─────────┘ └─────────┘ └─────┬─────┘
│
▼
Result{SQL, Args} ◄── ┌───────────┐ 执行期(每次调用)
│ Executor │
│ 树遍历执行 │
└───────────┘
- 解析期:源码 → Lexer → Token 流 → Parser → AST。只做一次,产出
Template对象 - 执行期:AST + 变量 → Executor 遍历树 → 输出 SQL 字符串 + 收集参数
- Template 线程安全:解析后不可变,可并发
Execute
包结构
utpl/
├── engine.go # Engine, New(), Option
├── template.go # Template, Execute(), Result
├── safety.go # RawPolicy 配置
├── error.go # 错误类型
└── internal/
├── lexer.go # 词法分析器
├── parser.go # 递归下降解析器
├── executor.go # AST 执行器
├── node.go # AST 节点定义
├── expr.go # 表达式解析与求值
├── context.go # 变量上下文
├── placeholder.go # 占位符生成
├── builtin.go # 内置函数
└── include.go # @include 路径解析与展开
对外仅暴露根包 4 个文件,内部实现全部在 internal/ 下。
与其他方案对比
| 特性 | u-tpl | text/template | squirrel | MyBatis |
|---|---|---|---|---|
| 语言 | Go | Go | Go | Java/XML |
| 参数化 SQL | #{} 自动收集 |
不支持 | 需手动提取 Args | #{} |
${} 原样替换 + 安全策略 |
内置白名单 | 无 | 无 | 无限制 |
| 控制流语法 | @if(){} @for(){} |
{{if}} |
Go 代码 | <if> <foreach> |
| Args 分离 | 自动 | 不支持 | 需手动 | 自动 |
| AI 生成友好度 | 高(类 Go + MyBatis) | 低(管道语法) | 低(链式调用) | 中(XML 冗长) |
| 零依赖 | 是 | 是 | 是 | 否 |
| 模板存储 | Go 字符串 / 文件 | Go 字符串 / 文件 | Go 代码 | XML 文件 |
u-tpl 的定位:当你的核心需求是「把模板渲染成带参数的 SQL」时,u-tpl 比通用模板引擎更安全(内置参数化),比查询构建器更直观(SQL 即代码)。
性能
基准测试(单核,中等复杂度模板 ~10 个节点):
| 场景 | 速度 | 说明 |
|---|---|---|
简单替换 (3 个 #{}) |
~500 ns/op | 200 万次/秒 |
| 条件 WHERE (3 个 if) | ~2 μs/op | 50 万次/秒 |
| 循环 IN (10 元素) | ~5 μs/op | 20 万次/秒 |
解析开销约 10μs/模板,仅发生一次。性能瓶颈通常在数据库 I/O,不在模板引擎。
常见问题
#{} 和 ${} 怎么选?
默认用 #{}(安全参数化)。只有表名、列名等无法参数化的场景才用 ${},并建议配置白名单。
@if 语法和 Go 的 if 有什么区别?
@if 是模板指令,用 @ 前缀与 SQL 内容区分。语法结构({ }、else)与 Go 完全一致,无额外学习成本。
变量嵌套访问(如 user.name)支持吗?
支持。Execute 的 map 值可以是嵌套 map,模板中用 user.name 访问。
模板从哪里加载?
u-tpl 不管模板加载。你可以从文件、数据库、配置中心、嵌入资源中读取字符串后传给 Parse。
模板中可以用 Go 的 text/template 吗?
可以,但不推荐用于 SQL。text/template 不区分「参数化值」和「原样替换」,无法产出 SQL + Args 组合。
License
MIT