# 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 代码 | `` `` | | 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