Files
u-tpl/README.md
绝尘 861d58d718 新增: u-tpl SQL 模板引擎完整实现
- Lexer/Parser/Executor 三阶段架构
- #{param} 参数化 + ${raw} 原样替换 + 白名单安全策略
- @if/@for/@tpl/@include/@namespace 控制流
- 表达式引擎: 比较、逻辑、nil 检查、len() 内置函数
- 支持 ?/$1/:1 多数据库占位符风格
- 零依赖,纯 Go 标准库实现
2026-04-01 00:27:50 +08:00

28 KiB
Raw Blame History

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 借鉴 GoLLM 训练数据覆盖率极高
  • 高性能 — 解析一次 AST执行只需树遍历~500ns/op简单模板

快速开始

package main

import (
	"fmt"
	"gitea.1216.top/lxy/u-tpl"
)

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 gitea.1216.top/lxy/u-tpl

语法参考

概览

语法 用途 替换为 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 是值,与 Go range 一致)。

循环内置变量:

变量 类型 说明
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"
    "gitea.1216.top/lxy/u-tpl"
)

//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 — ${} 值被安全策略拦截
}

变量未定义时默认返回 ExecErrorfail-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