- Lexer/Parser/Executor 三阶段架构
- #{param} 参数化 + ${raw} 原样替换 + 白名单安全策略
- @if/@for/@tpl/@include/@namespace 控制流
- 表达式引擎: 比较、逻辑、nil 检查、len() 内置函数
- 支持 ?/$1/:1 多数据库占位符风格
- 零依赖,纯 Go 标准库实现
1014 lines
28 KiB
Markdown
1014 lines
28 KiB
Markdown
# 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(简单模板)
|
||
|
||
## 快速开始
|
||
|
||
```go
|
||
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...)
|
||
}
|
||
```
|
||
|
||
## 安装
|
||
|
||
```bash
|
||
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 字符串**。
|
||
|
||
```sql
|
||
-- 模板
|
||
SELECT * FROM users WHERE id = #{user_id}
|
||
|
||
-- 输入: {"user_id": 42}
|
||
-- SQL: SELECT * FROM users WHERE id = ?
|
||
-- Args: [42]
|
||
```
|
||
|
||
```sql
|
||
-- 多参数
|
||
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 关键字)。
|
||
|
||
```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` 一致,支持多行和行内写法。
|
||
|
||
```sql
|
||
-- 可选 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}
|
||
}
|
||
```
|
||
|
||
```go
|
||
// 场景 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 分支(支持行内写法):**
|
||
|
||
```sql
|
||
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
|
||
```
|
||
|
||
**多条件分支(else if 链,同行分隔):**
|
||
|
||
```sql
|
||
@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 语法。
|
||
|
||
```sql
|
||
-- 动态 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 | 是否是最后一个元素 |
|
||
|
||
### `# 注释` — 注释
|
||
|
||
行注释,`#` 之后到行末的内容在渲染时被忽略。
|
||
|
||
```sql
|
||
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") { }` — 命名语句块
|
||
|
||
一个模板文件可以包含多个命名语句块,执行时按名称取用。
|
||
|
||
```sql
|
||
-- 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})
|
||
}
|
||
```
|
||
|
||
```go
|
||
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
|
||
```
|
||
|
||
```sql
|
||
-- tpl/common/tenant.tpl
|
||
AND tenant_id = #{tenant_id}
|
||
@if(department_id != nil) { AND department_id = #{department_id} }
|
||
```
|
||
|
||
```sql
|
||
-- tpl/common/pagination.tpl
|
||
LIMIT #{limit} OFFSET #{offset}
|
||
```
|
||
|
||
```sql
|
||
-- 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")
|
||
```
|
||
|
||
```go
|
||
// 配置引入解析器 — 挂在 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` 显式声明命名空间来区分。
|
||
|
||
```sql
|
||
-- 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})
|
||
}
|
||
```
|
||
|
||
```sql
|
||
-- 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})
|
||
}
|
||
```
|
||
|
||
```go
|
||
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 对象上有效
|
||
- 移动文件、重命名目录不影响模板引用
|
||
|
||
---
|
||
|
||
## 实战示例
|
||
|
||
### 条件搜索 + 排序 + 分页
|
||
|
||
```sql
|
||
-- 模板: 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}
|
||
```
|
||
|
||
```go
|
||
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
|
||
|
||
```sql
|
||
-- 模板: batch_insert
|
||
INSERT INTO users (name, email, status) VALUES
|
||
@for(u, range users) {
|
||
(#{u.name}, #{u.email}, #{u.status}),
|
||
}
|
||
```
|
||
|
||
```go
|
||
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
|
||
|
||
```sql
|
||
-- 模板: 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}
|
||
```
|
||
|
||
```go
|
||
// 只更新 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]
|
||
```
|
||
|
||
### 跨数据库兼容
|
||
|
||
```go
|
||
// 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. 安全第一:默认 `#{}`,谨慎 `${}`
|
||
|
||
```sql
|
||
-- 好 — 值走参数化,不可能注入
|
||
WHERE id = #{user_id} AND status = #{status}
|
||
|
||
-- 差 — 值直接拼入 SQL
|
||
WHERE id = ${user_id} AND status = ${status}
|
||
```
|
||
|
||
**原则**:能用 `#{}` 就用 `#{}`。只有表名、列名、SQL 关键字等无法参数化的场景才用 `${}`。
|
||
|
||
### 2. `${}` 必须配白名单
|
||
|
||
生产环境始终为 `${}` 配置安全策略,防止注入。
|
||
|
||
```go
|
||
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 关键字,多数数据库不支持参数化排序方向。
|
||
|
||
```sql
|
||
-- 好 — 关键字写在模板里
|
||
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
|
||
|
||
-- 差 — 多数数据库不会正确处理参数化的 ASC/DESC
|
||
ORDER BY ${sort_col} #{direction}
|
||
```
|
||
|
||
### 4. WHERE 条件用 `1=1` 起手 + `@if` 追加
|
||
|
||
避免动态拼接 WHERE 关键字本身,减少模板复杂度。
|
||
|
||
```sql
|
||
-- 好 — 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。
|
||
|
||
```sql
|
||
-- 好 — 空列表时整个 IN 条件不出现
|
||
@if(ids != nil && len(ids) > 0) {
|
||
AND id IN (@for(id, range ids) {#{id}, })
|
||
}
|
||
```
|
||
|
||
> `@for` 对 nil 和空集合均不输出任何内容,不报错。但仍建议在 `@if` 中做前置检查,避免生成 `IN ( )` 语法。
|
||
|
||
```go
|
||
// 传入 nil 或空切片 → IN 子句不会出现
|
||
tpl.Execute(map[string]any{"ids": nil})
|
||
// SQL: SELECT * FROM users WHERE 1=1
|
||
```
|
||
|
||
### 6. 模板启动时解析,不要每次请求 Parse
|
||
|
||
`Parse` 有开销(~10μs),应在服务启动时完成,`Template` 对象可并发安全使用。
|
||
|
||
```go
|
||
// 好 — 启动时解析,存入 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)
|
||
}
|
||
```
|
||
|
||
```go
|
||
// 差 — 每次请求都 Parse
|
||
func SearchOrders(params map[string]any) (*utpl.Result, error) {
|
||
tpl, _ := engine.Parse("order_search", orderSearchSQL) // 每次都解析
|
||
return tpl.Execute(params)
|
||
}
|
||
```
|
||
|
||
### 7. LIMIT 始终参数化,不要省略
|
||
|
||
无 LIMIT 的查询可能返回全表数据,造成性能问题和内存溢出。
|
||
|
||
```sql
|
||
-- 好 — LIMIT 参数化,调用方控制
|
||
SELECT * FROM users WHERE status = #{status} LIMIT #{limit}
|
||
```
|
||
|
||
```go
|
||
// 代码层提供默认值
|
||
if _, ok := params["limit"]; !ok {
|
||
params["limit"] = 100
|
||
}
|
||
```
|
||
|
||
### 8. 注释标注业务意图
|
||
|
||
模板注释帮助后续维护者(包括 AI)理解 SQL 的业务目的。
|
||
|
||
```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 末尾的逗号由引擎自动修剪。
|
||
|
||
```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
|
||
```
|
||
|
||
```sql
|
||
-- 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}
|
||
```
|
||
|
||
```go
|
||
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 语法。
|
||
|
||
#### 方案二:外部文件加载
|
||
|
||
模板放文件系统,支持运行时修改,适合需要热更新的场景。
|
||
|
||
```go
|
||
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`,一个文件管理一个领域。
|
||
|
||
```go
|
||
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)
|
||
```
|
||
|
||
```sql
|
||
-- 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
|
||
// 差 — 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 参考
|
||
|
||
### 创建引擎
|
||
|
||
```go
|
||
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`) |
|
||
|
||
### 解析模板
|
||
|
||
```go
|
||
tpl, err := engine.Parse(name, source) // 返回错误
|
||
tpl := engine.MustParse(name, source) // 出错 panic
|
||
```
|
||
|
||
`Template` 对象是线程安全的,解析一次可并发使用。
|
||
|
||
### 执行模板
|
||
|
||
```go
|
||
// 无命名块 — 整个模板作为一个语句
|
||
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 结构
|
||
|
||
```go
|
||
type Result struct {
|
||
SQL string // 渲染后的 SQL(含占位符)
|
||
Args []any // 参数值(按占位符顺序)
|
||
}
|
||
```
|
||
|
||
### 错误处理
|
||
|
||
```go
|
||
result, err := tpl.Execute(vars)
|
||
if err != nil {
|
||
// ParseError — 模板语法错误,包含行号和列号
|
||
// ExecError — 执行时错误(如变量未定义)
|
||
// UnsafeRawError — ${} 值被安全策略拦截
|
||
}
|
||
```
|
||
|
||
> **变量未定义时默认返回 `ExecError`**(fail-fast)。如需宽松模式(未定义变量输出空字符串),可用 `WithStrictMode(false)`。
|
||
|
||
---
|
||
|
||
## 安全策略
|
||
|
||
`${}` 原样替换有注入风险。通过 `RawPolicy` 限制允许的值。
|
||
|
||
### 白名单模式(推荐)
|
||
|
||
```go
|
||
engine := utpl.New(
|
||
utpl.WithRawPolicy(utpl.RawAllowlist(map[string][]string{
|
||
"table_name": {"users", "products", "orders"},
|
||
"sort_col": {"id", "name", "created_at", "amount"},
|
||
})),
|
||
)
|
||
```
|
||
|
||
```go
|
||
// 合法 — 在白名单中
|
||
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
|
||
```
|
||
|
||
### 黑名单模式
|
||
|
||
```go
|
||
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
|