From 71d7f6590a3d18a644e372644b5f03a2b1066a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 31 Mar 2026 17:32:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4:=20u-tpl?= =?UTF-8?q?=20=E9=A1=B9=E7=9B=AE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 1013 ++++++++++++++++++++++++++++++++++++++++++++++++++++ todo.md | 13 + 3 files changed, 1027 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 todo.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f9d2ae --- /dev/null +++ b/README.md @@ -0,0 +1,1013 @@ +# 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" + "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...) +} +``` + +## 安装 + +```bash +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 字符串**。 + +```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" + "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 语法。 + +#### 方案二:外部文件加载 + +模板放文件系统,支持运行时修改,适合需要热更新的场景。 + +```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 代码 | `` `` | +| 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 diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..3326090 --- /dev/null +++ b/todo.md @@ -0,0 +1,13 @@ + +# u-tpl + +实现一套 模板解析引擎, +高性能,易于维护,易于理解, + +使用场景,现代 sql 模板管理,ai友好 + +语言:golang + +# 维护到 git 仓库, (暂时先不要提交,只暂缓到本地) +git remote add origin https://gitea.1216.top/lxy/u-tpl.git +git push -u origin main \ No newline at end of file