# EasyTL **Repository Path**: dromara/easy-tl ## Basic Information - **Project Name**: EasyTL - **Description**: 一个功能丰富的轻量级字符串模板引擎,支持类 JavaScript 的表达式语法、控制流语句(if/for/switch)、高级特性(空安全操作符、Elvis 表达式、正则匹配、区间字面量等) - **Primary Language**: Java - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 14 - **Forks**: 5 - **Created**: 2025-11-09 - **Last Updated**: 2026-08-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # EasyTL - 轻量级字符串模板引擎 ## 项目简介 EasyTL 是一个轻量级的字符串模板引擎,基于 Java 8 开发,无第三方依赖,提供类似 JavaScript 的表达式语法支持。 ## 技术栈 - **Java**: JDK 8+ - **构建工具**: Maven - **依赖**: 无第三方依赖 ## 快速开始 ### 1. 添加依赖 在您的 Maven 项目中添加 EasyTL 依赖: ```xml com.github.easytl easy-tl 1.0.0 ``` ### 2. 基本使用 ```java import org.dromara.easytl.TemplateEngine; import org.dromara.easytl.Template; import org.dromara.easytl.Context; // 创建模板引擎 TemplateEngine engine = new TemplateEngine(); // 编译模板 Template template = engine.compile("你好,{name}!欢迎来到 {city}。"); // 准备数据 Context context = new Context(); context.put("name", "张三"); context.put("city", "北京"); // 渲染输出 String result = template.render(context); // 输出:你好,张三!欢迎来到 北京。 ``` ### 3. 使用 EasyTL 工具类(推荐) EasyTL 提供了更简洁的 API,支持链式调用: ```java import org.dromara.easytl.EasyTL; import java.util.HashMap; import java.util.Map; // 快速渲染 Map data = new HashMap<>(); data.put("name", "张三"); String result = EasyTL.render("你好,{name}!", data); // 输出:你好,张三! // 链式调用 String result = EasyTL.template("你好,{name}!") .put("name", "张三") .render(); // 编译后多次渲染 Template template = EasyTL.compile("你好,{name}!"); Context ctx = new Context(); ctx.put("name", "张三"); String r1 = EasyTL.render(template, ctx); // 创建自定义引擎 TemplateEngine engine = EasyTL.engine() .cacheEnabled(true) .maxCacheSize(1024) .strictMode(true) .build(); // 验证模板语法 ValidationResult result = EasyTL.validate("你好,{name}!"); if (result.isValid()) { // 模板语法正确 } ``` ### 4. 对象属性访问 ```java public class User { private String name; private int age; // getters and setters public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } // 使用对象 User user = new User(); user.setName("李四"); user.setAge(25); Context context = new Context(); context.put("user", user); Template template = engine.compile("用户:{user.name},年龄:{user.age}"); String result = template.render(context); // 输出:用户:李四,年龄:25 ``` ### 5. 条件判断 ```java // 使用三元表达式 Template template = engine.compile("状态:{age >= 18 ? '成年人' : '未成年人'}"); Context context = new Context(); context.put("age", 20); String result = template.render(context); // 输出:状态:成年人 // 使用 if 语句块 Template template = engine.compile( "{% if score >= 90 }优秀{/% if }" + "{% if score >= 60 && score < 90 }及格{/% if }" + "{% if score < 60 }不及格{/% if }" ); ``` ### 6. 循环遍历 ```java // 遍历集合 Template template = engine.compile( "{% for item in items }{item}\n{/% for }" ); List items = Arrays.asList("苹果", "香蕉", "橙子"); Context context = new Context(); context.put("items", items); String result = template.render(context); // 输出: // 苹果 // 香蕉 // 橙子 ``` ### 7. 空安全操作 ```java // 空安全访问符 ?. Template template = engine.compile("地址:{user?.address?.city ?? '未知'}"); Context context = new Context(); context.put("user", null); // user 为 null String result = template.render(context); // 输出:地址:未知 ``` ## 功能特性 ### 1. 纯文本支持 支持直接编写普通字符串文本内容,原样输出。 **示例:** ``` Hello World! 这是一段普通文本。 ``` **输出:** ``` Hello World! 这是一段普通文本。 ``` ### 2. 表达式嵌入 支持在文本中嵌入表达式,支持三种语法格式: #### 2.1 单花括号语法 `{表达式}` ``` Hello {user.name}! ``` #### 2.2 双花括号语法 `{{表达式}}` ``` Hello {{user.name}}! ``` #### 2.3 美元符号语法 `${表达式}` ``` Hello ${user.name}! ``` #### 2.4 语法等价性 以上三种语法格式在功能上完全等价,都可以用于嵌入表达式: ``` // 以下三种写法效果相同 Hello {user.name}! Hello {{user.name}}! Hello ${user.name}! ``` #### 2.5 Token类型和ASTNode类型差异 虽然三种语法格式功能等价,但它们在解析过程中会产生不同的Token类型和ASTNode类型: - `{表达式}` → 生成 `SINGLE_BRACE_TOKEN` 和 `SingleBraceExpressionNode` - `{{表达式}}` → 生成 `DOUBLE_BRACE_TOKEN` 和 `DoubleBraceExpressionNode` - `${表达式}` → 生成 `DOLLAR_BRACE_TOKEN` 和 `DollarBraceExpressionNode` 这种设计允许在后续处理中区分不同的语法来源,便于调试、分析和特殊处理。 #### 2.6 格式兼容性 字符串模板中的纯文本部分支持 JSON、XML 等各种格式,JSON 字符串中的花括号 `{}` 会被视为纯文本,不会与 `{表达式}` 语法产生冲突 **格式兼容示例:** ``` // JSON 格式支持 {"name": "{user.name}", "age": {user.age}, "city": "北京"} // 输出示例:{"name": "张三", "age": 25, "city": "北京"} // XML 格式支持 {user.name}{user.age} // 输出示例:张三25 // 复杂 JSON 结构 { "server": { "host": "{server.host}", "port": {server.port}, "enabled": {server.enabled} } } ``` ### 3. 表达式语法(类 JavaScript) #### 3.1 变量访问 支持访问对象的属性: ``` {user.name} {user.age} {company.department.name} ``` #### 3.2 参数下标访问 支持通过数字索引访问外部传入的位置参数,语法为 `{N}`、`${N}` 或 `{{N}}`,其中 `N` 为从 0 开始的参数索引。 ``` {0} // 访问第一个参数 ${1} // 访问第二个参数 {{2}} // 访问第三个参数 ``` **使用 EasyTL 工具类:** ```java // 链式调用方式 String result = EasyTL.template("姓名:{0},年龄:{1}") .arguments("张三", 25) .render(); // 输出:姓名:张三,年龄:25 // 快速渲染方式 String result = EasyTL.render("你好,{0}!", "李四"); // 输出:你好,李四! ``` **使用 TemplateBuilder:** ```java String result = new TemplateBuilder() .source("价格:${0} 元,数量:${1}") .arguments(99.9, 10) .render(); // 输出:价格:99.9 元,数量:10 ``` **使用 Context API:** ```java Template template = engine.compile("第一个:{0},第二个:{1}"); Context context = new Context(); context.setArguments("苹果", "香蕉"); String result = template.render(context); // 输出:第一个:苹果,第二个:香蕉 ``` **说明:** - 参数索引从 0 开始,`{0}` - 如果访问超出范围的索引,抛出 `ArgumentIndexOutOfBoundsException` - 三种语法格式 `{N}`、`${N}`、`{{N}}` 功能等价 **参数变量语法 `{$N}`:** 除 `{N}` 外,还支持带 `$` 前缀的参数变量语法 `{$N}`,同样支持 `{$0}`、`${$0}`、`{{$0}}` 三种包装形式: ``` {$0} // 访问第一个参数 {$1} // 访问第二个参数 ``` **`{N}` 与 `{$N}` 的区别:** | 语法 | 索引越界 | 值为 null | | :--- | :--- | :--- | | `{N}` | 抛出 `ArgumentIndexOutOfBoundsException` | 正常返回 null(渲染为空串) | | `{$N}` | 抛出 `ArgumentIndexOutOfBoundsException` | 同样抛出 `ArgumentIndexOutOfBoundsException` | 即 `{$N}` 是更严格的参数访问方式,要求参数必须存在且非 null,适用于参数缺失即视为错误的场景。 **默认参数名 `{$}`:** `$` 是默认参数名,未定义同名变量时等价于 `{$0}`,即引用第一个参数,同样支持 `{$}`、`${$}`、`{{$}}` 三种包装形式: ``` 你好,{$}! // 等价于:你好,{$0}! {$.name} // 等价于 {$0.name},访问第一个参数的 name 属性 {$ ?? 'default'} // 与 {$0 ?? 'default'} 行为一致 ``` **覆盖规则:** 当可见作用域中存在名为 `$` 的变量时,`$` 优先使用该变量值,不再回退到第一个参数: ```java // Java 代码中覆盖 Context context = new Context(); context.put("$", "X"); context.setArguments("Y"); // 模板 "{$}" 渲染结果:"X" ``` ``` // 模板内覆盖 {% let $ = 'X' %}{$} // 输出:X ``` **Lambda 中的 `$`:** Lambda 调用时会将自身入参设为参数作用域,Lambda 体内的 `$`(以及 `$0`、`{0}`)引用 Lambda 自身入参而非模板参数: ``` {% let f = (a, b) -> $ %}{f('first', 'second')} // 输出:first {% let f = $ -> $ * 2 %}{f(21)} // 输出:42(显式参数名 $ 覆盖绑定) ``` **说明:** - `{$}` 的严格语义与 `{$0}` 完全一致:参数缺失、越界或值为 null 时抛出 `ArgumentIndexOutOfBoundsException` - 覆盖优先级:`context.put("$", v)`、`{% let $ = ... %}`、Lambda 参数名 `$` 均优先于参数回退 - 行为变更:Lambda 体内 `$0`、`{0}` 由「模板参数」变为「Lambda 自身入参」,与 `$` 语义保持一致 #### 3.3 方法调用 支持调用对象的方法: ``` {user.getName()} {user.setName('张三')} {list.size()} {str.substring(0, 5)} ``` #### 3.4 字面量 EasyTL 支持多种类型的字面量,用于在表达式中直接表示常量值。 ##### 3.4.1 整数字面量 支持普通整数、长整数和大整数: **普通整数:** ``` {user.setAge(25)} {count + 100} {-42} ``` **长整数(L 结尾):** ``` {1234567890123L} {999999999999999L} {-1000000000L} ``` **大整数(数值超出 long 范围时自动转换为 BigInteger):** ``` {123456789012345678901234567890} {999999999999999999999999999999} ``` **说明:** - 普通整数范围:-2,147,483,648 到 2,147,483,647(int 类型) - 长整数范围:-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807(long 类型) - 大整数:超出 long 范围的整数自动使用 BigInteger 类型,支持任意精度 - 所有整数类型都支持负数,直接在数字前加负号即可 ##### 3.4.2 浮点数字面量 支持普通浮点数和大数(高精度小数): **普通浮点数:** ``` {product.setPrice(99.99)} {0.618} {3.14159} {-1.5} ``` **科学计数法:** ``` {1.23e10} {1.5e-8} {9.99E+20} ``` **大数(BigDecimal,用于高精度计算):** ``` {99.999999999999} {123456.789012345678} ``` **说明:** - 普通浮点数使用 double 类型,精度约 15-17 位有效数字 - 支持科学计数法表示法(e 或 E),如 1.23e10 表示 12,300,000,000 - 当小数位数超过 double 精度范围时,自动使用 BigDecimal 类型 - BigDecimal 类型支持任意精度的小数运算,适用于金融计算等场景 ##### 3.4.3 字符串字面量 支持单引号、双引号两种格式的字符串: **单引号字符串:** ``` {user.setName('张三')} {'Hello World'} {'It\'s a beautiful day'} // 转义单引号 ``` **双引号字符串:** ``` {user.setName("李四")} {"Hello World"} {"He said \"Hello\""} // 转义双引号 ``` **转义字符:** ``` {"第一行\n第二行"} // 换行符 {"列1\t列2\t列3"} // 制表符 {"路径:C:\\Windows"} // 反斜杠 {"引号:\"内容\""} // 双引号 {'单引号:\'内容\''} // 单引号 ``` **说明:** - 单引号和双引号字符串在功能上完全等价 - 字符串内可以使用反斜杠 `\` 进行转义 - 支持的转义字符:`\n`(换行)、`\t`(制表符)、`\\`(反斜杠)、`\'`(单引号)、`\"`(双引号) - 字符串可以包含 Unicode 字符和中文 ##### 3.4.4 字符串模板字面量 支持使用反引号包裹的字符串模板,可以在字符串中嵌入表达式: **基本语法:** ``` {`Hello {user.name}!`} {`您好,{name},欢迎来到 {city}`} ``` **嵌套表达式:** ``` {`总价:{price * quantity}元`} {`用户 {user.name} 的年龄是 {user.age} 岁`} {`订单状态:{order.status == 1 ? '已完成' : '进行中'}`} ``` **说明:** - 字符串模板使用反引号(`` ` ``)包裹 - 在模板中使用 `{表达式}` 嵌入动态内容 - 支持任意复杂的表达式,包括运算、方法调用、三元运算符等 - 字符串模板会在运行时计算所有嵌入的表达式,并将结果拼接成最终字符串 - 支持多行字符串,保留换行符和缩进 ##### 3.4.5 区间字面量 支持定义数值区间,常用于范围判断和迭代: **全闭区间(包含两端边界):** ``` {-100..100} // 包含 -100 和 100,范围是 [-100, 100] {0..10} // 包含 0 和 10,范围是 [0, 10] ``` **全开区间(不包含边界):** ``` {-100>..<100} // 不包含 -100 和 100,范围是 (-100, 100) {0>..<10} // 不包含 0 和 10,范围是 (0, 10) ``` **前闭后开区间(包含起始值,不包含结束值):** ``` {-100..<100} // 包含 -100,不包含 100,范围是 [-100, 100) {0..<10} // 包含 0,不包含 10,范围是 [0, 10) ``` **前开后闭区间(不包含起始值,包含结束值):** ``` {-100>..100} // 不包含 -100,包含 100,范围是 (-100, 100] {0>..10} // 不包含 0,包含 10,范围是 (0, 10] ``` **区间应用示例:** ``` // 判断数值是否在区间内 {score in 0>..<100 ? '有效分数' : '无效分数'} // 区间迭代(配合循环语句使用) {% for i in 1..10} 第 {i} 项 {/% for} // 区间作为参数传递 {list.subList(0>..5)} ``` **说明:** - 区间字面量用于表示一个连续的数值范围 - 四种区间类型对应数学中的开区间、闭区间概念 - `>` 符号表示不包含左边界,`..` 表示区间连接符,`<` 符号表示不包含右边界 - 全闭区间:`a..b` → [a, b] - 全开区间:`a>....b` → (a, b] - 区间常用于范围检查(in 运算符)和循环迭代 - 区间的起始值必须小于结束值 ##### 3.4.6 列表字面量 支持使用方括号定义列表(数组),列表中可以包含任意类型的表达式: **基本语法:** ``` [表达式1, 表达式2, 表达式3, ...] ``` **整数列表:** ``` {[1, 2, 3, 4, 5]} {[2, 3, 5, 7, 11, 13]} {[-10, -20, -30]} ``` **字符串列表:** ``` {['red', 'green', 'blue']} {["北京", "上海", "广州", "深圳"]} {['张三', "李四", '王五']} ``` **混合类型列表:** ``` {[1, 'hello', 3.14, true, null]} {[user.id, user.name, user.age]} ``` **表达式列表:** ``` {[a + b, c * d, sqrt(x)]} {[user.name, user.getName(), "hello " + user[1].name]} {[score * 0.9, score + bonus, maxScore - penalty]} ``` **嵌套列表:** ``` {[[1, 2, 3], [4, 5, 6], [7, 8, 9]]} {[['a', 'b'], ['c', 'd']]} {[1, [2, 3], [4, [5, 6]]]} ``` **空列表:** ``` {[]} ``` **列表应用示例:** ``` // 定义列表并遍历 {% let fruits = ['苹果', '香蕉', '橙子'] %} {% for fruit in fruits} - {fruit} {/% for} // 使用 in 运算符判断元素是否在列表中 {role in ['admin', 'moderator'] ? '管理员' : '普通用户'} // 列表作为方法参数 {processor.handleItems([item1, item2, item3])} // 访问列表元素 {% let numbers = [10, 20, 30, 40] %} 第一个数字:{numbers[0]} 最后一个数字:{numbers[3]} // 动态构建列表 {% let userInfo = [user.id, user.name, user.age, user.email] %} {userInfo[1]} 的年龄是 {userInfo[2]} 岁 ``` **说明:** - 列表字面量使用方括号 `[]` 包裹,元素之间用逗号 `,` 分隔 - 列表中可以包含任意类型的元素:整数、浮点数、字符串、布尔值、null 等 - 支持混合类型列表,同一个列表中可以包含不同类型的元素 - 列表中的元素可以是任意复杂的表达式,包括变量访问、方法调用、运算表达式等 - 支持嵌套列表,列表的元素可以是另一个列表 - 空列表用 `[]` 表示 - 列表在 Java 中会被转换为 `java.util.ArrayList` 类型 - 可以通过索引访问列表元素,索引从 0 开始:`list[0]`、`list[1]` 等 - 列表可以用于 `in` 运算符进行元素包含判断 - 列表可以用于 `for` 循环进行遍历 - 列表可以作为方法参数传递 ##### 3.4.7 哈希表字面量 支持使用花括号定义哈希表(Map),用于存储键值对: **基本语法:** ``` {键1: 表达式1, 键2: 表达式2, 键3: 表达式3, ...} ``` **标识符作为键:** ``` {% let user = {name: "张三", age: 25, city: "北京"} %} {% let config = {debug: true, timeout: 3000, retries: 3} %} {% let point = {x: 100, y: 200} %} ``` **字符串作为键:** ``` {% let map = {"a": 1, "b": 2, "c": 3} %} {% let settings = {"max-size": 1000, "min-value": 10} %} {% let data = {"user-name": "李四", "user-id": 12345} %} ``` **数字作为键:** ``` {% let statusMap = {0: "待处理", 1: "进行中", 2: "已完成"} %} {% let scoreMap = {90: "优秀", 80: "良好", 60: "及格"} %} ``` **混合类型的键:** ``` {% let mixed = { name: "混合示例", "string-key": "字符串键", 100: "数字键", active: true } %} ``` **值为表达式:** ``` {% let calculated = { sum: a + b, product: a * b, average: (a + b) / 2, message: '结果是 ' + (a + b) } %} ``` **嵌套哈希表:** ``` {% let nested = { user: {name: "张三", age: 25}, address: {city: "北京", street: "长安街"}, contact: {phone: "123456", email: "user@example.com"} } %} ``` **空哈希表:** ``` {% let empty = {} %} ``` **哈希表应用示例:** ``` // 定义用户信息 {% let user = { id: 1001, name: "张三", age: 28, email: "zhangsan@example.com", role: "admin" } %} 用户ID:{user.id} 用户名:{user.name} 年龄:{user.age} // 定义配置项 {% let config = { apiUrl: "https://api.example.com", timeout: 5000, retryCount: 3, enableCache: true } %} API地址:{config.apiUrl} 超时时间:{config.timeout}ms 重试次数:{config.retryCount} // 状态码映射 {% let statusMessages = { 200: "成功", 404: "未找到", 500: "服务器错误" } %} {% switch httpStatus } {% case 200 }{statusMessages[200]} {% case 404 }{statusMessages[404]} {% case 500 }{statusMessages[500]} {/% switch } // 使用方括号访问 {% let data = {"user-name": "李四", "user-id": 10086} %} 用户:{data["user-name"]},ID:{data["user-id"]} // 动态构建哈希表 {% let userMap = { name: user.getName(), age: user.getAge(), email: user.getEmail(), status: user.isActive() ? "激活" : "禁用" } %} ``` **说明:** - 哈希表字面量使用花括号 `{}` 包裹,键值对之间用逗号 `,` 分隔 - 每个键值对使用冒号 `:` 分隔键和值 - 键可以是标识符(如 `name`)、字符串(如 `"name"`)或数字(如 `0`) - 当键是标识符时,会被自动转换为字符串 - 值可以是任意类型的表达式:数字、字符串、布尔值、null、变量、运算表达式等 - 支持嵌套哈希表,哈希表的值可以是另一个哈希表 - 空哈希表用 `{}` 表示 - 哈希表在 Java 中会被转换为 `java.util.HashMap` 类型 - 可以使用点号访问标识符类型的键:`map.key` - 可以使用方括号访问任意类型的键:`map["key"]`、`map[0]` - 哈希表可以用于 `in` 运算符进行键存在判断 - 哈希表可以作为方法参数传递 #### 3.5 布尔值和空值 ``` {user.setActive(true)} {user.setDeleted(false)} {user.setAddress(null)} ``` #### 3.6 数组/列表访问 ``` {list[0]} {array[index]} {map['key']} {list[-1]} // 最后一个元素(负数下标从末尾计数,越界仍抛异常) ``` #### 3.7 算术运算 ``` {a + b} {a - b} {a * b} {a / b} {a % b} ``` **数字类型提升规则:** 算术运算按 `int < long < double < BigInteger < BigDecimal` 的优先级自动提升为两个操作数中较高的类型: ``` {7 / 2} // 输出 3(两个 int 相除为整数除法) {7.0 / 2} // 输出 3.5(提升为 double) {2L + 3} // 输出 5(提升为 long) ``` **除零行为:** - int/long/BigInteger/BigDecimal 除以零:抛出 `TemplateRuntimeException`(/ by zero) - double 除以零:得到 `Infinity`(不报错) - double 对零取模:得到 `NaN`(不报错) - BigDecimal 除法采用 `ROUND_HALF_UP` 舍入,小数位数取左操作数的标度 **字符串拼接规则(`+` 运算符):** - 任一操作数为字符串时,`+` 执行拼接,另一操作数按 `toString()` 转换 - null 参与拼接时按空串处理:`{'a' + null}` 输出 `a` - 遵循左结合:`{'a' + 1 + 2}` 输出 `a12`,而 `{1 + 2 + 'a'}` 输出 `3a` - 两个操作数均为 null,或 null 与数字做算术运算时抛出 `TemplateRuntimeException` #### 3.8 比较运算 ``` {a > b} {a < b} {a >= b} {a <= b} {a == b} {a != b} ``` **相等性比较(`==` / `!=`)的语义:** - `==` 采用 `Objects.equals` 语义,**不做数字跨类型比较** - `{1 == 1.0}` 输出 `false`,`{1 == 1L}` 同样为 `s`(int 与 double/long 类型不同即不相等) - `{null == null}` 输出 `true`,`{null == x}` 输出 `false` - 注意:`switch` 语句的 case 匹配同样采用 `==` 语义 **关系比较(`<` / `>` / `<=` / `>=`)的语义:** - 与 `==` 不同,关系运算**会做数字跨类型提升比较**:`{1 < 1.5}` 输出 `true` - 两个操作数都实现 `Comparable` 时优先按 `compareTo` 比较(字符串按字典序) - 与 null 比较时,null 视为最小值:`{null < 1}` 输出 `true` #### 3.9 逻辑运算 ``` {a && b} {a || b} {!a} ``` **说明:** - `&&` 和 `||` 支持短路求值 - 条件判定采用真值(truthy)语义,以下值被视为假(falsy): - `null` - 布尔值 `false` - 数字 `0`、`0.0` - 空字符串 `""` - 空集合(Collection)、空映射(Map) - 其余值均为真(truthy) ``` {!0} // 输出 true(0 为假值) {!''} // 输出 true(空字符串为假值) {!'hello'} // 输出 false ``` #### 3.10 三元运算符 ``` {age >= 18 ? '成年' : '未成年'} {score >= 60 ? '及格' : '不及格'} ``` #### 3.11 链式调用 ``` {user.getName().toUpperCase()} {list.get(0).getAddress().getCity()} ``` #### 3.12 空安全操作符 支持使用 `?.` 进行空安全访问,当对象为 null 时,不会继续执行后续操作,直接返回 null: ``` {user?.name} {user?.address?.city} {list?.get(0)?.name} ``` **示例说明:** - 如果 `user` 为 null,`{user?.name}` 将返回 null,而不会抛出空指针异常 - 可以链式使用空安全操作符,如 `{user?.address?.city}` - 空安全操作符也适用于方法调用,如 `{user?.getName()?.toUpperCase()}` #### 3.13 Elvis 表达式 支持使用 `??` 运算符提供默认值,当左侧表达式为 null 或空时,返回右侧的默认值: ``` {name ?? "匿名用户"} {user.email ?? "未设置邮箱"} {score ?? 0} ``` **示例说明:** - 如果 `name` 为 null,`{name ?? "匿名用户"}` 将返回 "匿名用户" - 可以与空安全操作符结合使用:`{user?.name ?? "匿名用户"}` - Elvis 表达式的右侧可以是任意表达式:`{value ?? getDefaultValue()}` #### 3.14 正则表达式字面量 支持使用斜杠包裹的正则表达式字面量,语法与 JavaScript 类似: ``` {/[a-zA-Z]/} {/\d+/} {/\w+[\d\w]*/} {/[\d]+/gi} ``` **语法格式:** - 基本格式:`/pattern/` - 带标志位:`/pattern/flags` **支持的标志位:** - `g` - 全局匹配 - `i` - 忽略大小写 - `m` - 多行模式 **示例说明:** - `/[a-zA-Z]/` - 匹配任意单个字母 - `/\d+/` - 匹配一个或多个数字 - `/\w+/i` - 匹配一个或多个单词字符,忽略大小写 - `/^[\d]{3,6}$/` - 匹配3到6位数字 #### 3.15 正则匹配运算符 支持使用 `=~` 运算符进行正则表达式匹配,返回布尔值: ``` {name =~ /\w+[\d\w]*/} {email =~ /^[\w.-]+@[\w.-]+\.\w+$/} {phone =~ /^1[3-9]\d{9}$/} ``` **示例说明:** - 如果 `name` 匹配正则表达式 `/\w+[\d\w]*/`,返回 `true`,否则返回 `false` - 可以用于条件判断:`{email =~ /^[\w.-]+@/ ? '有效邮箱' : '无效邮箱'}` - 左侧必须是字符串类型,右侧必须是正则表达式字面量 - 匹配采用 `Matcher.find()` 语义(子串匹配,不要求全串匹配):`{'abc123' =~ /\d+/}` 输出 `true`;如需全串匹配请使用 `^` 和 `$` 锚点 #### 3.16 正则不匹配运算符 支持使用 `!=~` 运算符进行正则表达式不匹配判断,返回布尔值: ``` {name !=~ /[\d]+/} {username !=~ /^admin$/i} {password !=~ /^123456$/} ``` **示例说明:** - 如果 `name` 不匹配正则表达式 `/[\d]+/`,返回 `true`,否则返回 `false` - 等价于 `!(name =~ /[\d]+/)` - 可用于验证:`{username !=~ /^(admin|root)$/i ? '用户名可用' : '用户名被禁用'}` #### 3.17 条件表达式 支持使用 `if` 表达式根据条件返回不同的值,与条件语句块不同,条件表达式是一个可以作为值使用的表达式。 **语法格式:** ``` if (条件表达式) 语句块 if (条件表达式) 语句块1 else 语句块2 if (条件表达式1) 语句块1 else if (条件表达式2) 语句块2 else 语句块3 ``` **语句块格式:** ``` { 语句1; 语句2; 语句3 } ``` 或使用换行符分隔: ``` { 语句1 语句2 语句3 } ``` **返回值规则:** - 语句块的返回值为最后一条语句的返回值 - 条件表达式的返回值为被执行的语句块的返回值 **使用范围:** - 可以在 `{...}` 表达式上下文中使用:`{if (a == 1) {"A"} else {"B"}}` - 可以在自闭合模板语句块中作为赋值右侧使用:`{% let x = if (...) {...} else {...} %}` - 可以作为独立语句使用:`{% if (...) {...} %}`(仅执行副作用,可不带 else) - 不能在纯文本中直接使用 **重要规则:** - 当 if 表达式用于赋值(放在 `=` 右边)时,**必须包含 else 语句块**,否则编译报错 - 这是因为赋值语句需要确保变量一定会被赋予一个值 - 如果不需要 else 分支,请使用条件语句块 `{% if ... }{/% if }` 代替 **基本示例:** ``` // ✅ 正确:赋值表达式带有 else {% let result = if (a == 1) {"A"} else {"B"} %} {% let status = if (age >= 18) {"成年"} else {"未成年"} %} // ✅ 正确:{...} 表达式上下文中使用 {if (a == 1) {"A"} else {"B"}} // ❌ 错误:赋值表达式缺少 else(编译报错) {% let result = if (a == 1) {"A"} %} // ✅ 正确:非赋值场景可以不带 else(仅执行副作用) {% if (debug) { log("debug mode") } %} ``` **多条语句示例:** ``` {% let name = if (score >= 90) { log("评定为优秀") "A" } else if (score >= 60) { log("评定为及格") "B" } else { log("评定为不及格") "C" } %} ``` **说明:** - 在上面的例子中,`name` 的值为 `"A"`、`"B"` 或 `"C"`(语句块最后一条语句的值) - 语句块中的前面各条语句用于执行副作用(如日志、方法调用) - 条件表达式会根据条件选择执行对应的语句块 - 支持 `else if` 进行多条件判断 - 注意:语言中没有普通赋值语句(`x = 值`),只有 `let` 声明和 `+=` 等复合赋值 - if 表达式的语句块内可以使用 `let` 声明变量;声明的变量与外围同作用域(块外可访问),而 for 循环语句和 Lambda 的块体为独立子作用域 **应用示例:** ``` {% let greeting = if (hour < 12) {"早上好"} else if (hour < 18) {"下午好"} else {"晚上好"} %} 欢迎您,{greeting}! {% let message = if (status == 1) { log("订单待处理") "您的订单正在处理中" } else if (status == 2) { log("订单处理中") "订单处理中,请稍候" } else { log("订单已完成") "订单已完成" } %} {message} ``` **与三元运算符的区别:** - 三元运算符:`{age >= 18 ? '成年' : '未成年'}`,只能是简单的表达式 - 条件表达式:`if (age >= 18) {...} else {...}`,可以包含多条语句,支持 else if - 条件表达式更适合复杂的条件逻辑和需要执行多条语句的场景 **与条件语句块的区别:** - 条件语句块:`{% if ... }...{/% if }`,用于控制模板输出,是标准模板语句块 - 条件表达式:`if (...) {...}`,用于计算值,只能在自闭合模板语句块中使用 #### 3.18 in 表达式 支持使用 `in` 运算符判断元素是否在集合或区间中,返回布尔值: ##### 3.18.1 集合包含判断 判断变量是否在集合(数组、List、Set 等)中: ``` {a in numbers} {user.id in adminIds} {status in ['pending', 'processing', 'completed']} ``` **示例说明:** - 如果变量 `a` 在集合 `numbers` 中,返回 `true`,否则返回 `false` - 支持任何实现了 `Collection` 接口的集合类型 - 右侧可以是变量引用的集合,也可以是数组字面量 **应用示例:** ``` {role in ['admin', 'moderator'] ? '管理员权限' : '普通用户'} {userId in blacklist ? '已被封禁' : '正常用户'} {color in ['red', 'green', 'blue'] ? '有效颜色' : '无效颜色'} ``` ##### 3.18.2 区间包含判断 判断数值是否在指定区间内: ``` {num in 0..100} {age in 18>..<65} {score in 60>..100} {temperature in -10..<40} ``` **示例说明:** - 如果变量 `num` 在区间 `0..100` 中(包含边界),返回 `true`,否则返回 `false` - 支持所有区间类型:全闭区间 `a..b`、全开区间 `a>....b` - 区间边界支持整数和浮点数 - 左侧必须是数值类型 **应用示例:** ``` {score in 0>..<100 ? '有效分数' : '无效分数'} {age in 18>..<60 ? '适龄工作者' : '不在工作年龄范围'} {temperature in -10>..<35 ? '正常温度' : '温度异常'} {price in 0>..1000 ? '价格合理' : '价格超出范围'} ``` ##### 3.18.3 Map 键包含判断 判断键是否存在于 Map 中: ``` {key in userMap} {'username' in config} {productId in inventory} ``` **示例说明:** - 如果键 `key` 存在于 Map `userMap` 中,返回 `true`,否则返回 `false` - 支持任何实现了 `Map` 接口的映射类型 - 可用于判断配置项是否存在 **应用示例:** ``` {'debug' in config ? config.get('debug') : false} {userId in userCache ? '缓存命中' : '缓存未命中'} ``` ##### 3.18.4 字符串包含判断 判断子字符串是否包含在字符串中: ``` {'admin' in username} {'@' in email} {keyword in content} ``` **示例说明:** - 如果子字符串 `'admin'` 包含在字符串 `username` 中,返回 `true`,否则返回 `false` - 字符串匹配区分大小写 - 等价于 Java 的 `String.contains()` 方法 **应用示例:** ``` {'@' in email ? '邮箱格式可能正确' : '邮箱格式错误'} {'test' in username ? '测试账号' : '正式账号'} {keyword in article.content ? '包含关键词' : '不包含关键词'} ``` ##### 3.18.5 !in 表达式(不包含判断) 支持使用 `!in` 运算符判断元素是否不在集合、区间、Map 或字符串中,返回布尔值。`!in` 是 `in` 的否定形式。 **语法格式:** ``` {变量 !in 集合/区间/Map/字符串} ``` **集合不包含判断:** ``` {a !in numbers} {user.id !in blacklist} {status !in ['deleted', 'banned', 'suspended']} ``` **示例说明:** - 如果变量 `a` 不在集合 `numbers` 中,返回 `true`,否则返回 `false` - 等价于 `!(a in numbers)` - 适用于黑名单、排除列表等场景 **区间不包含判断:** ``` {age !in 0>..18} {score !in 60>..100} {temperature !in -10>..<40} ``` **示例说明:** - 如果数值不在指定区间内,返回 `true`,否则返回 `false` - 支持所有区间类型 **Map 键不包含判断:** ``` {key !in userMap} {'admin' !in permissions} {productId !in inventory} ``` **示例说明:** - 如果键不存在于 Map 中,返回 `true`,否则返回 `false` - 可用于判断配置项是否缺失 **字符串不包含判断:** ``` {'admin' !in username} {'@' !in text} {keyword !in content} ``` **示例说明:** - 如果子字符串不包含在字符串中,返回 `true`,否则返回 `false` - 等价于 Java 的 `!String.contains()` 方法 **应用示例:** ``` {userId !in blacklist ? '正常用户' : '已被封禁'} {age !in 0>..18 ? '成年人' : '未成年人'} {'test' !in username ? '正式账号' : '测试账号'} {'debug' !in config ? '生产环境' : '调试模式'} {score !in 0>..60 ? '及格' : '不及格'} ``` #### 3.19 JSONPath 语法 EasyTL 内置 JSONPath 风格的数据查询语法,可直接用于 `{...}` / `{{...}}` / `${...}` 中的任意表达式后缀链。查询结果为列表(继承自 `ArrayList`),因此可以直接用于 `{% for %}` 循环、`.size()` 方法、Elvis 表达式等场景,渲染输出与普通列表一致(如 `[a, b]`)。 本节示例统一使用以下书店数据(`store.book` 为 4 本书的列表,前两本无 `isbn`,前三本有 `sizes`;`store.bicycle` 为自行车): ```java // store.book: // [0] {category='reference', author='Nigel Rees', title='Sayings of the Century', price=8.95, sizes=['S']} // [1] {category='fiction', author='Evelyn Waugh', title='Sword of Honour', price=12.99, sizes=['S','M']} // [2] {category='fiction', author='Herman Melville', title='Moby Dick', price=8.99, isbn='0-553-21311-3', sizes=['L']} // [3] {category='fiction', author='J. R. R. Tolkien', title='The Lord of the Rings', price=22.99, isbn='0-395-19395-8'} // store.bicycle: {color='red', price=19.95} ``` ##### 3.19.1 通配符 `[*]` 与 `.*` 通配符收集对象的全部子节点:List/数组 → 全部元素;Map → 全部 value;Bean → 全部属性值。`[*]` 与 `.*` 两种写法完全等价: ``` {store.book[*].author} // [Nigel Rees, Evelyn Waugh, Herman Melville, J. R. R. Tolkien] {store.book.*.title} // 与 {store.book[*].title} 等价 {store.*.size()} // 2(store 的全部子节点:book 列表 + bicycle) {store.*[1].color} // red(通配结果后可用数值下标取元素) {store.*.price} // [19.95](没有 price 的节点自动跳过) ``` **示例说明:** - 通配结果为列表,继续接 `.属性` 时按投影语义逐元素取值(元素为 null 或无该属性时跳过) - 作用于标量(字符串/数字/布尔)或 null 时抛出 `TemplateRuntimeException` ##### 3.19.2 切片 `[start:end(:step)]` 数组/列表切片,`start` 含、`end` 不含,均可省略,支持负数(从末尾计数),越界自动夹取;`step` 为可选步长(默认 1),负数表示反向取值,0 表示空结果: ``` {store.book[1:4]} // 第 2~4 本书 {store.book[:2].size()} // 2(省略 start) {store.book[2:].size()} // 2(省略 end) {store.book[-2:][0].title} // Moby Dick(负数从末尾计数) {store.book[0:99].size()} // 4(越界自动夹取) {store.book[:]} // 全部元素 {store.book[0:4:2].size()} // 2(步长 2,取下标 0、2) {store.book[::2].size()} // 2(start/end 均省略 + 步长) {store.book[::-1]} // 全部元素逆序(负步长反向取值) {store.book[3:0:-1].size()}// 3(下标 3、2、1) {store.book[::0].size()} // 0(步长 0 结果为空) ``` **示例说明:** - 仅支持 List/数组,作用于其他类型(如 Map、标量)抛出 `TemplateRuntimeException` - 与三元下标无冲突:`a[b ? c : d]` 仍按三元表达式解析 ##### 3.19.3 过滤 `[?(expr)]` 对 List/数组逐元素按谓词筛选,谓词内用 `@` 引用当前元素(见 3.19.5): ``` {store.book[?(@.price < 10)].title} // [Sayings of the Century, Moby Dick] {store.book[?(@['price'] < 10)].title} // @['key'] 写法等价 {store.book[?(@.price < 10)][0].title} // Sayings of the Century(过滤后可继续取下标) ``` **存在性过滤**(筛选"具有某属性"的元素): ``` {store.book[?(@.isbn)].title} // [Moby Dick, The Lord of the Rings](保留有 isbn 的书) {store.book[?(!@.isbn)].title} // [Sayings of the Century, Sword of Honour](保留无 isbn 的书) ``` **作用于 Map**(对 Map 的全部 value 应用谓词,`@` 引用 value): ``` {store.bicycle[?(@ > 10)]} // [19.95](bicycle 的 value 中只有 price 大于 10) {store[?(@.price)][0].color} // red(store 的 value 中只有 bicycle 有 price) ``` **示例说明(宽松模式):** - 谓词求值期间处于宽松模式:属性不存在(Map 无 key / Bean 无 getter)或中间节点为 null 时按不匹配处理,不抛异常 - 缺失属性参与大小比较(如 `@.price < 10` 中某元素没有 price)同样按不匹配处理,不中断整体过滤 - 存在性判定走真值语义:属性存在但值为 `false`/`0`/`""`/`null` 时同样视为不匹配(与 `if` 条件判定一致) - 支持 List/数组/Map(Map 对 value 过滤),作用于其他类型抛出 `TemplateRuntimeException` ##### 3.19.4 递归下降 `..name` / `..*` / `..[...]` 在任意深度收集匹配的成员值(先序 DFS:先查对象自身,Map 含 key / Bean 有该属性即收集,再递归子节点): ``` {store.book..price} // [8.95, 12.99, 8.99, 22.99] {% for p in store.book..price }{p};{/% for } // 8.95;12.99;8.99;22.99; ``` 除 `..name` 外,还支持以下形式(对后代树中的每个节点应用选择器,类型不适用的节点自动跳过): ``` {store..*} // 全部后代值(store 下所有层级的叶子与容器值) {store..['price']} // [8.95, 12.99, 8.99, 22.99, 19.95](等价 ..price,含 bicycle 的 price) {store..[0]} // 每个 List/数组的首元素:[第一本书, 'S', 'S', 'L'] {store..[0:2].size()} // 6(对每个 List/数组取切片 [0:2] 后合并) {store..[?(@.isbn)].title} // [Moby Dick, The Lord of the Rings](对每个 List/Map 应用过滤) {store..[0, 'price']} // 多选递归:对每个节点依次应用各选择器 ``` **示例说明:** - 自动防止循环引用(不会因数据成环而栈溢出) - `..name` 要求左值为路径类节点(成员/下标/方法调用/JSONPath 节点);`..*` 与 `..[...]` 的左值也可以是标识符(如 `store..*`),这两种形态不构成合法区间,无歧义 - 注意 `..` 与区间字面量的二义消解规则,见 3.19.9 ##### 3.19.5 当前节点 `@` `@` 引用过滤谓词中的当前元素,仅能在 `[?(...)]` 内使用;`@.price`、`@[0]`、`@['key']` 等后缀链自然可用: ``` {store.book[?(@.price > 20)].title} // [The Lord of the Rings] {store.book[?(@.sizes anyof ['M'])].title} // [Sword of Honour](谓词内可嵌套集合操作符) ``` **示例说明:** - 在过滤表达式之外使用 `@`(如 `{@.price}`)抛出带位置信息的 `TemplateRuntimeException` ##### 3.19.6 负数下标与 Union 多选 `[,]` List/数组支持下标为负数(从末尾计数,`-1` 为最后一个元素),读取与复合赋值均适用: ``` {store.book[-1].title} // The Lord of the Rings {store.book[-4].title} // Sayings of the Century {store.book[*][-1].title} // The Lord of the Rings(NodeList 同样适用) ``` 方括号内可用逗号组合多个选择器(下标/键、切片、通配、过滤可混合),按声明顺序合并结果;未命中的选择器(键不存在、下标越界、类型不适用)静默跳过: ``` {store.book[0, 2].title} // [Sayings of the Century, Moby Dick] {store.bicycle['color', 'price']} // [red, 19.95] {store.book[0:2, 3].size()} // 3(切片与下标混合) {store.book[-1, 0][0].title} // The Lord of the Rings(负数下标参与多选) {store.bicycle['color', 'missing']} // [red](不存在的键跳过) {store.book[0, 99].size()} // 1(越界下标跳过) ``` **示例说明:** - 多选结果为列表(`NodeList`),可继续 `.属性` 投影、`[?(...)]` 过滤等 - 作用于 null 时抛出 `TemplateRuntimeException` ##### 3.19.7 RFC 9535 函数扩展 内置 RFC 9535 函数扩展,可在任意表达式位置使用(含过滤谓词内)。这些函数通过 `TemplateFunction` 扩展接口实现(见下文"自定义函数"),未硬编码进语法规则: | 函数 | 说明 | | :--- | :--- | | `length(x)` | 字符串长度;List/数组/Map 的元素个数;其他类型(含 null)抛异常 | | `count(x)` | 节点计数:`null` → 0,标量 → 1,List/数组/Map → 元素个数 | | `match(str, regex)` | 全串正则匹配;regex 可为字符串或正则字面量 | | `search(str, regex)` | 子串正则搜索;regex 可为字符串或正则字面量 | | `value(x)` | 单元素集合取唯一元素;空或多元素 → null;标量原样返回 | ``` {length('hello')} // 5 {count(store.book[?(@.price < 10)])} // 2 {match('abc123', '[a-z]+[0-9]+')} // true(全串匹配) {match('ABC', /^[a-z]+$/i)} // true(正则字面量 + 标志位) {search('abc123', '[0-9]+')} // true(子串搜索) {value(store.book[?(@.isbn == '0-553-21311-3')]).title} // Moby Dick // 过滤谓词内组合使用 {store.book[?(length(@.title) > 15)].title} // [Sayings of the Century, The Lord of the Rings] {store.book[?(count(@.sizes) > 1)].title} // [Sword of Honour] {store.book[?(match(@.author, '.*Rees.*'))].title} // [Sayings of the Century] ``` **自定义函数(TemplateFunction SPI):** 实现 `org.dromara.easytl.runtime.TemplateFunction` 接口并注册到 Context,即可以同样的方式调用: ```java Context context = new Context(); context.registerFunction(new TemplateFunction() { @Override public String getName() { return "double"; } @Override public Object call(List args) { return ((Number) args.get(0)).doubleValue() * 2; } }); // 模板:{double(21)} → 42.0 ``` 也支持链式 API: ```java String result = EasyTL.template("{double(21)}") .function(new DoubleFunction()) .render(); ``` **示例说明:** - 函数调用解析优先级:导入类/上下文变量(Class、Lambda)> 注册函数 > 内置函数,即上下文中的同名 Lambda 会遮蔽内置函数 - 注册的函数沿作用域链继承(for 循环、Lambda 等子作用域内可用);子作用域可覆盖父作用域的同名函数 - 函数参数在调用前完成求值;参数个数/类型不匹配时抛出 `TemplateRuntimeException`(在过滤谓词内按"不匹配"处理,不中断过滤) **契约断言函数 `assert(...)`:** 内置契约编程支持,断言失败时抛出 `AssertionException`(`TemplateRuntimeException` 的子类),断言通过时不做任何事(渲染为空串): | 形式 | 说明 | | :--- | :--- | | `assert(cond)` | cond 为假时抛出 `AssertionException`,携带友好错误消息 | | `assert(cond, message)` | cond 为假时抛出异常,自定义消息作为前缀:`message ==> Assertion failed: ...` | | `assert(cond, lambda)` | cond 为假时执行 lambda(入参为 `AssertionException` 对象),执行后不再抛出,渲染继续 | `assert` 可识别条件表达式的源码结构,比较(`== != > >= < <=`)、包含/集合(`in !in subsetof anyof noneof`)、正则(`=~ !=~`)运算会给出包含左右操作数源码与实际值的友好消息;函数/方法调用条件直接使用调用源码提示。`Expected` / `Actual` 的含义按运算符分为三类:`==` 时 Expected 为左值、Actual 为右值;属于/匹配类(`in !in subsetof anyof noneof =~ !=~`,即左边是否属于右边、是否是右边的一部分、是否匹配右边)Expected 为左值应满足的约束描述(如 `an Integer in <[1, 2, 3]>`)、Actual 为左值(被检验项);其余(`!= > >= < <=`)Expected 为左值应满足的约束描述(如 `an Integer that was not <2>`)、Actual 为右值: ``` {assert(a > b)} // 失败:Assertion failed: expected a to be greater than b // Expected : an Integer that was greater than <2> // Actual : b = <2> {assert(a != b)} // 失败:Assertion failed: expected a not to be equal to b // Expected : an Integer that was not <2> // Actual : b = <2> {assert(100 * n == calc(price))} // 失败:Assertion failed: expected 100 * n to be equal to calc(price) // Expected : 100 * n = <300> // Actual : calc(price) = <312> (操作数按源码原样展示) {assert(role in roles)} // 失败:Assertion failed: expected role to be in roles // Expected : a String in <[admin, moderator]> // Actual : role = {assert(match(name, 'x'))} // 失败:Assertion failed: // Expected : match(name, 'x') to return true // Actual : {assert(x)} // 失败:Assertion failed: // Expected : x to be truthy // Actual : <0> {assert(score >= 60, 'score too low')} // 失败:score too low ==> Assertion failed: expected score to be at least 60 // Expected : an Integer that was at least <60> // Actual : 60 = <60> {assert(cond, e -> log.add(e.getMessage()))} // 失败时执行 lambda 记录异常,不再抛出,渲染继续 ``` **说明:** - 断言失败时,为生成消息,二元条件的左右操作数会被二次求值;对纯读取表达式无影响,含副作用的调用会执行两次 - 与其他内置函数一致,上下文中的同名变量(如 `{% let assert = ... %}`)会遮蔽内置 `assert` ##### 3.19.8 集合操作符 `subsetof` / `anyof` / `noneof` 二元中缀关键字操作符,优先级与结合性和 `in` / `!in` 完全一致,可用于任意表达式位置(包括过滤谓词内部): | 操作符 | 含义 | 左为空集合时 | | :--- | :--- | :--- | | `subsetof` | 左集合是右集合的子集 | `true` | | `anyof` | 左集合与右集合有交集 | `false` | | `noneof` | 左集合与右集合无交集(等价于 `!(anyof)`) | `true` | ``` {['S','M'] subsetof ['S','M','L']} // true {['S','X'] subsetof ['S','M','L']} // false {['M','L'] anyof ['M']} // true {['X'] anyof ['M','L']} // false {['X'] noneof ['M','L']} // true {'M' anyof ['M','L']} // true(标量按单元素集合处理) {[] subsetof ['S']} // true(空左操作数) {[] anyof ['S']} // false {[] noneof ['S']} // true {store.book[?(@.sizes anyof ['M'])].title} // [Sword of Honour](过滤内使用) ``` **操作数归一化规则(两侧对称):** - `null` → 空集合(故 `{missing subsetof ['M']}` 为 `true`、`{missing anyof ['M']}` 为 `false`、`{missing noneof ['M']}` 为 `true`) - `Collection` → 本身 - 数组(含基本类型数组)→ List - 其他标量 → 单元素集合 **示例说明:** - 元素相等性判定与 `in` 操作符一致(`equals` 语义,不做数字类型强转) ##### 3.19.9 注意事项 - **`..` 与区间的二义消解**:`..` 后跟标识符时,仅当左值为路径类节点(成员访问、下标访问、方法调用或 JSONPath 节点)才解析为递归下降 `..name`;`..*` 与 `..[...]` 不构成合法区间,左值为路径类节点或标识符(如 `store..*`)即可解析为递归下降;其余情况解析为区间。因此 `{a..b}`、`{1..10}`、`{% for i in 1..10 }` 仍是区间 - **已知边界**:`user.min..user.max`(`..` 左值是路径类节点且后跟标识符)会被解析为递归下降(`..user` 再 `.max`)而非区间。如需以路径表达式为端点的区间,先用 `let` 绑定端点: ``` {% let min = user.min %} {% let max = user.max %} {min..max} ``` - **`@` 仅限过滤表达式内使用**,在外部使用抛出 `TemplateRuntimeException` - **通配/切片/过滤/递归下降/多选的结果不可作为赋值目标**(编译期拒绝) - 本期不支持:`$` 根节点、过滤省略括号写法 `[?@.p<10]`、脚本表达式 `[(@.length-1)]` #### 3.20 for 循环语句 支持使用 `for` 循环语句遍历集合或区间,执行副作用操作。 **语法格式:** ``` for (变量 in 表达式) { 语句块 } for (索引变量, 值变量 in 表达式) { 语句块 } ``` **使用范围:** - 可以在 `{...}` 表达式上下文中使用:`{for (x in list) { list2.add(x) }}` - 可以作为独立语句使用:`{% for (...) {...} %}` - 不能在纯文本中直接使用 **与循环语句块的区别:** - 循环语句块:`{% for ... }...{/% for }`,用于控制模板输出,是标准模板语句块 - for 循环语句:`for (...) {...}`,用于执行副作用操作,是脚本语句 **基本语法示例:** ``` // 单变量循环 {% for (user in userList) { names.add(user.name) } %} // 带索引的循环 {% for (i, user in userList) { user.setIndex(i) processedList.add(user) } %} // 遍历区间 {% for (i in 1..10) { sum = sum + i } %} ``` **语句块格式:** ``` { 语句1 语句2 语句3 } ``` 或使用分号分隔: ``` { 语句1; 语句2; 语句3 } ``` **返回值规则:** - for 循环语句执行完成后返回 null - 循环体内的语句按顺序执行 - 循环变量在循环结束后不可访问 **完整示例:** ``` {% let names = [] let scores = [] for (user in userList) { names.add(user.name) scores.add(user.score) } %} 用户数量:{names.size()} ``` **带索引的循环示例:** ``` {% let orderedList = [] for (index, item in items) { item.setOrder(index + 1) orderedList.add(item) } %} ``` **遍历区间示例:** ``` {% let sum = 0 for (i in 1..100) { sum = sum + i } %} 总和:{sum} ``` **嵌套循环示例:** ``` {% let matrix = [] for (i in 1..3) { let row = [] for (j in 1..3) { row.add(i * j) } matrix.add(row) } %} ``` **应用示例:** ``` {% // 数据预处理 let validUsers = [] for (user in allUsers) { if (user.age >= 18) { validUsers.add(user) } } // 数据统计 let totalScore = 0 for (i, user in validUsers) { totalScore = totalScore + user.score user.setRank(i + 1) } let avgScore = totalScore / validUsers.size() %} 有效用户数:{validUsers.size()} 平均分数:{avgScore} ``` **说明:** - for 循环语句主要用于执行副作用操作,如修改对象、填充列表等 - 循环变量的作用域仅限于循环体内 - 支持遍历任何实现了 `Iterable` 接口的集合 - 支持遍历区间字面量 - 循环体内可以使用 let 声明变量,这些变量在循环外部也可访问 - 与 if 表达式类似,for 循环语句也是一种脚本语句,不是模板控制语句 #### 3.21 Lambda 表达式(箭头函数) 支持使用箭头函数语法定义匿名函数,Lambda 是一等值,可以赋值给变量、存入列表、作为参数传递并调用。 **语法格式:** ``` 参数 -> 表达式 参数 -> { 语句块 } ``` **参数形式:** ``` () -> 42 // 无参数 x -> x * 2 // 单参数(可省略括号) (x) -> x * 2 // 单参数(带括号) (a, b) -> a + b // 多参数 (int a, String b) -> ... // 带类型声明的参数 ``` **函数体形式:** - 表达式体:`x -> x * 2`,返回表达式的值 - 语句块体:`(a, b) -> { ... }`,花括号内是裸语句序列(不需要 `{% %}`),支持 `let` 声明、`return` 语句和任意表达式语句;没有 `return` 时返回最后一条语句的值,空块返回 null **基本示例:** ``` {% let fn = () -> 42 %} {fn()} // 输出 42 {% let doubler = x -> x * 2 %} {doubler(5)} // 输出 10 {% let adder = (a, b) -> a + b %} {adder(3, 4)} // 输出 7 ``` **语句块体与 return 示例:** ``` {% let safeDiv = (a, b) -> { if (b == 0) { return 0 } a / b } %} {safeDiv(10, 2)} // 输出 5 {safeDiv(10, 0)} // 输出 0(return 提前返回) ``` **闭包按值捕获:** Lambda 在定义时拷贝当前所有可见变量的值(按值捕获),之后变量重新赋值不影响已捕获的值: ``` {% let x = 10 %} {% let fn = a -> a + x %} {% let x = 20 %} {fn(5)} // 输出 15(捕获的是 x=10,而不是 20) ``` **嵌套 Lambda(柯里化):** ``` {% let outer = x -> y -> x + y %} {% let inner = outer(10) %} {inner(5)} // 输出 15 ``` **Lambda 存入列表:** ``` {% let fns = [(x) -> x * 2, (x) -> x * 3] %} {% let fn0 = fns.get(0) %} {% let fn1 = fns.get(1) %} {fn0(5)} // 输出 10 {fn1(5)} // 输出 15 ``` **与 Java 函数式接口的适配:** 模板中调用 Java 方法时,实参中的 Lambda 会自动适配为形参声明的函数式接口类型(如 `Function`、`Predicate`、`Consumer` 等)。Java 侧也可以通过 `LambdaAdapter.adapt(lambdaObject, Function.class)` 显式适配。 **说明:** - 调用时实参个数必须与形参个数一致,否则抛出运行时异常 - 参数名不能重复,重复参数名会在编译期报语法错误 - 带类型声明的参数在调用时做类型校验(仅校验,不做强制转换) - `return` 语句只能用于 Lambda 语句块体(以及 `Eval.eval` 的源码)中,不能用于模板顶层的 `{% %}` 块 - Lambda 与 Java 函数式接口的自动适配在某些 Java 版本下可能受模块访问限制影响 - 直接输出 Lambda 变量(如 `{fn}`)会渲染为内部对象描述串,无实际意义,应调用后输出结果 #### 3.22 类型转换表达式 支持使用 `(类型) 表达式` 进行显式类型转换,语法类似 Java 的强制转换: **语法格式:** ``` {(int) '123'} {(String) 3.14} {(double) score} ``` **内置支持的类型:** `int`/`Integer`、`long`/`Long`、`double`/`Double`、`float`/`Float`、`short`/`Short`、`byte`/`Byte`、`char`/`Character`、`boolean`/`Boolean`、`String`、`BigDecimal`、`BigInteger` **示例:** ``` {(int) 3.99} // 输出 3(截断取整,非四舍五入) {(int) -3.9} // 输出 -3 {(long) '9999999999'} // 输出 9999999999 {(String) 3.14} // 输出 3.14 {(boolean) 1} // 输出 true(数字按 != 0 判定) {(char) 65} // 输出 A {(BigDecimal) '3.14159'} // 输出 3.14159 {(int)(double) '3.14'} // 输出 3(支持链式转换) ``` **自定义类型转换:** 对于通过 `import` 导入的类,如果值已经是目标类型则原样返回,否则尝试使用单参数构造函数创建: ```java engine.importClass(java.util.ArrayList.class, "AL"); ``` ``` {(AL) ['a', 'b']} // 输出 [a, b] ``` **说明:** - 类型名只能是单个标识符(简单类名或 import 别名),不支持泛型和全限定名 - 数值转换采用截断方式(直接去掉小数部分) - null 转换结果为 null(渲染为空串) - `(boolean)` 对数字按 `!= 0` 判定,对字符串按 `Boolean.valueOf` 判定(注意 `'abc'` 会转换为 `false`) - `(char)` 对数字取 `(char) intValue`,对字符串要求长度恰好为 1 - 转换失败(如 `(int) 'abc'`、未知类型名)在运行时抛出 `TemplateRuntimeException`,类型名在编译期不校验 - 类型转换与一元运算符同级,只作用于紧随的一元表达式:`(int) x * 2 + 1` 等价于 `((int) x) * 2 + 1` #### 3.23 自增自减表达式 支持 `++` 和 `--` 运算符,前缀、后缀形式均可,会真正修改变量的值: **语法格式:** ``` {++i} // 前缀:先加 1,返回新值 {i++} // 后缀:先返回旧值,再加 1 {--i} // 前缀:先减 1,返回新值 {i--} // 后缀:先返回旧值,再减 1 ``` **示例:** ``` // 设 i = 5 {i++} // 输出 5,之后 i 变为 6 {++i} // 输出 6 {i--} // 输出 5,之后 i 变为 4 {--i} // 输出 4 {++i * 2} // 可以用在更大的表达式中,i=1 时输出 4 ``` **说明:** - 操作数可以是标识符、成员访问(`obj.count++`)或索引访问(`arr[i]++`) - 其他目标(如 `{++5}`、`{'hello'++}`)在编译期报错 - 运算结果会回写到上下文变量、对象属性或列表元素中 - 操作数必须是数字类型 #### 3.24 复合赋值表达式 支持复合赋值运算符,`x op= 表达式` 等价于 `x = x op 表达式`,表达式返回赋值后的新值: **支持的运算符:** `+=`、`-=`、`*=`、`/=`、`%=`、`&=`、`|=`、`^=`、`<<=`、`>>=`、`>>>=` **示例:** ``` // 设 total = 10 {total += 5} // 输出 15,total 变为 15 {x -= 3} // x=10 时输出 7 {x *= 2} // x=3 时输出 6 {x /= 2} // x=10 时输出 5 {x %= 3} // x=10 时输出 1 {msg += ' world'} // msg='hello' 时输出 hello world(+= 支持字符串拼接) {x &= 6} // x=15 时输出 6(位与赋值) {x <<= 4} // x=1 时输出 16(左移赋值) {x += 2 + 3} // 右侧是完整表达式,x=1 时输出 6 ``` **说明:** - 复合赋值是右结合的,优先级最低,右侧先完整求值 - 赋值目标限定为标识符、成员访问或索引访问,其他目标(如 `{5 += 1}`)在编译期报错 - 复合赋值会修改上下文变量、对象属性或列表元素的值,并把新值作为表达式结果渲染出来 - 位运算只以复合赋值形式存在:语言中没有二元的 `&`、`|`、`^`、`<<`、`>>`、`>>>` 运算符 #### 3.25 对象构造表达式 语言中没有 `new` 关键字,对象构造通过"导入的类名 + 方法调用语法"完成,并支持独有的"构造 + 属性块"语法: **前提:** 类需要先通过 `{% import %}` 语句或 Java API(`engine.importClass(...)` 等)导入。 **语法形式:** ``` {ClassName(参数...)} // 普通构造调用 {ClassName {属性: 值, ...}} // 无参构造 + 逐个调用 setter {ClassName(参数...) {属性: 值}} // 有参构造 + setter ``` **示例:** ``` {ArrayList().size()} // 输出 0 {Math.sqrt(16)} // 静态方法调用,输出 4.0 {Math.PI} // 静态字段访问,输出 3.141592653589793 {TestPerson {name: 'Peter'}.getName()} // 无参构造 + setter,输出 Peter {TestPerson('John') {age: 25}.getAge()} // 有参构造 + setter,输出 25 {HashMap {name: 'Peter', age: 18}.get('name')} // 目标是 Map 时直接 put,输出 Peter ``` **说明:** - 属性块中的键值对会逐个转换为 setter 调用(`name: 'Peter'` → `setName('Peter')`) - 如果构造目标是 `Map`,属性块直接作为键值对 `put` 进 Map - 属性块也可以直接跟在上下文变量后面,此时是给已存在的实例设置属性 - 构造参数不匹配时由反射层抛出 `TemplateRuntimeException` ### 4. 模板语句块 模板引擎支持两种类型的模板语句块,用于在模板中嵌入控制逻辑和脚本代码,以区别于表达式内部使用的普通语句块: #### 4.1 标准模板语句块(有结束语句) 标准模板语句块用于流程控制,在开始语句和结束语句之间可以插入普通文本和其他模板语句块。 **语法格式:** ``` {% 语句名称 语句参数 } 文本内容和其他模板语句块 {/% 语句名称 } ``` **语法说明:** - 开始语句:以 `{%` 开始,以 `}` 结束 - 结束语句:以 `{/%` 开始,以 `}` 结束 - 结束语句的名称必须与开始语句的名称一致 - 标准模板语句块可以嵌套使用 - 模板语句块内部可以包含普通文本、表达式以及其他模板语句块 - 开始语句的结束符兼容 `}` 与 `%}` 两种写法(本文档统一使用 `}`);`{% case 值 }`、`{% default }` 与结束语句 `{/% 名称 }` 只接受 `}`;自闭合模板语句块(let/import/export/extends 等)必须以 `%}` 结束 **适用场景:** - 流程控制语句(if-else、for、switch) **示例:** ``` {% if score >= 60 }及格{/% if } ``` 当 `score >= 60` 为真时输出:`及格` #### 4.2 自闭合模板语句块(无结束语句) 自闭合模板语句块用于嵌入脚本代码,不需要结束语句,也不能在中间插入文本和其他模板语句块。 **语法格式:** 单条语句: ``` {% 语句 %} ``` 多条语句(用换行或分号分隔): ``` {% 语句1 语句2 语句3 %} ``` 或者: ``` {% 语句1; 语句2; 语句3 %} ``` **语法说明:** - 以 `{%` 开始,以 `%}` 结束 - 可以包含一条或多条语句 - 多条语句之间用换行符或分号分隔 - 不能在模板语句块中间插入文本 **适用场景:** - 变量声明和赋值(let) - 模板扩展(extends) - Java 类导入(import) - 其他单行或多行脚本代码 **示例:** 单条语句: ``` {% let name = 'EasyTL' %} ``` 多条语句: ``` {% let a = 1 let b = 2 let c = a + b %} ``` #### 4.3 空白行处理 当 `if`、`for`、`switch` 等标准模板语句块的开始语句或结束语句处于单独一行,且除了换行符、制表符和空格之外没有其他有意义的可渲染内容时,这一行将被作为空白行处理(这整个一行的内容都不会被渲染到结果中去)。 **说明:** - 虽然这些语句块所在的行不会被渲染,但它们仍然会正常执行逻辑处理功能 - 这个特性使得模板代码可以格式化得更美观,而不会在输出结果中产生多余的空白行 - 适用于所有标准模板语句块:`if`、`for`、`switch` 等 **示例:** 模板内容: ``` 商品列表: {% for item in items } - {item.name}: {item.price}元 {/% for } 总计:{total}元 ``` 渲染结果: ``` 商品列表: - 苹果: 5元 - 香蕉: 3元 - 橙子: 4元 总计:12元 ``` 如上例所示,其中 `{% for item in items }` 和 `{/% for }` 所在的行一整行都没被渲染出来,尽管它们发挥了逻辑处理的作用。 **其他语句块示例:** 条件语句块: ``` {% if flag == 1 } 文本内容 {/% if } ``` Switch 语句块: ``` {% switch status } {% case 1 }文本内容 {/% switch } ``` 在这些示例中,如果 `{% if ... }`、`{/% if }`、`{% switch ... }`、`{/% switch }` 等语句块标签单独占一行,且该行除了空白字符外没有其他可渲染内容,则该行会被作为空白行处理,不会出现在最终输出中。 ### 5. 条件语句块 支持在模板中使用条件语句块来根据条件动态输出内容。条件语句块是标准模板语句块,有开始和结束标签。 #### 5.1 基本 if 语句 **语法格式:** ``` {% if 条件表达式 } 文本内容 {/% if } ``` **示例:** ``` {% if flag == 1 }文本1{/% if } 文本2 ``` **说明:** - 当条件表达式 `flag == 1` 为真时,输出 "文本1文本2" - 当条件表达式为假时,只输出 "文本2" - 条件表达式支持所有表达式语法,包括比较运算、逻辑运算等 - 条件语句块内部可以包含普通文本、表达式以及其他模板语句块 #### 5.2 if-else 语句 **语法格式:** ``` {% if 条件表达式 } 文本1 {% else } 文本2 {/% if } ``` **示例:** ``` {% if flag == 1 }文本1{% else }文本2{/% if } 文本3 ``` **说明:** - 当 `flag == 1` 为真时,输出 "文本1文本3" - 当 `flag == 1` 为假时,输出 "文本2文本3" - `else` 块是可选的 #### 5.3 if-else if-else 语句 **语法格式:** ``` {% if 条件表达式1 } 文本1 {% else if 条件表达式2 } 文本2 {% else if 条件表达式3 } 文本3 {% else } 文本4 {/% if } ``` **示例:** ``` {% if flag == 1 }文本1{% else if flag == 2 }文本2{% else if flag == 3 }文本3{% else }文本4{/% if } 文本5 ``` **说明:** - 当 `flag == 1` 时,输出 "文本1文本5" - 当 `flag == 2` 时,输出 "文本2文本5" - 当 `flag == 3` 时,输出 "文本3文本5" - 当以上条件都不满足时,输出 "文本4文本5" - 可以有多个 `else if` 分支 - `else` 分支是可选的,如果没有 `else` 分支且所有条件都不满足,则不输出任何内容 **完整示例:** ``` 订单状态: {% if order.status == 'pending' }待处理{/% if } {% if order.status == 'processing' }处理中{/% if } {% if order.status == 'completed' }已完成{/% if } 用户等级: {% if user.score >= 1000 }钻石会员 {% else if user.score >= 500 }黄金会员 {% else if user.score >= 100 }白银会员 {% else }普通会员 {/% if } ``` ### 6. 循环语句块 支持在模板中使用循环语句块来遍历集合或区间,动态生成重复内容。循环语句块是标准模板语句块,有开始和结束标签。 #### 6.1 基本 for 循环 **语法格式:** ``` {% for 变量 in 集合 } 文本内容 {/% for } ``` **示例:** ``` {% for i in numbers } i = {i} {/% for } ``` **说明:** - `numbers` 是一个集合(数组、List 等)或区间 - `i` 是循环变量,每次迭代时会被赋值为集合中的当前元素 - 循环体内可以使用表达式 `{i}` 来访问当前循环变量的值 - 循环体内可以包含普通文本、表达式以及其他模板语句块 **示例应用:** ``` 用户列表: {% for user in userList } - 用户名:{user.name},年龄:{user.age} {/% for } ``` 假设 `userList` 包含三个用户,可能输出: ``` 用户列表: - 用户名:张三,年龄:25 - 用户名:李四,年龄:30 - 用户名:王五,年龄:28 ``` #### 6.2 带索引的 for 循环 **语法格式:** ``` {% for 索引变量, 值变量 in 集合 } 文本内容 {/% for } ``` **示例:** ``` {% for i, user in userList } 第{i}个用户名字叫:{user.name} {/% for } ``` **说明:** - 使用两个参数时,第一个参数 `i` 为索引值(从 0 开始) - 第二个参数 `user` 为当前循环到的值 - 索引和值都可以在循环体内使用 - 适用于需要同时获取元素位置和元素值的场景 **示例应用:** ``` 排行榜: {% for index, player in rankList } 第 {index + 1} 名:{player.name},得分:{player.score} {/% for } ``` 假设 `rankList` 包含三个玩家,可能输出: ``` 排行榜: 第 1 名:玩家A,得分:9500 第 2 名:玩家B,得分:8800 第 3 名:玩家C,得分:7600 ``` #### 6.3 区间循环 **语法格式:** ``` {% for i in 起始值>..结束值 } 文本内容 {/% for } ``` **示例:** ``` {% for i in 1>..<10 } 第 {i} 项 {/% for } ``` **说明:** - 支持使用区间字面量进行数值范围的循环 - 区间类型包括:全闭区间 `a..b`、全开区间 `a>....b` - 循环变量 `i` 会从起始值遍历到结束值(根据区间类型决定是否包含边界) - 适用于需要生成固定次数的重复内容 **示例应用:** ``` 九九乘法表: {% for i in 1>..<9 } {% for j in 1>.. 0; i-- }{i}{/% for } // 输出:54321 {% for let i = 1; i <= 16; i *= 2 }{i}{/% for } // 输出:124816 ``` **按下标遍历列表:** ``` {% for let i = 0; i < items.size(); i++ } - {items[i]} {/% for } ``` **嵌套经典 for 循环:** ``` {% for let i = 0; i < 3; i++ }{% for let j = 0; j < 3; j++ }({i},{j}){/% for }{/% for } // 输出:(0,0)(0,1)(0,2)(1,0)(1,1)(1,2)(2,0)(2,1)(2,2) ``` **说明:** - 初始化段必须提供,且必须是 `let 变量 = 表达式` 形式(只允许声明单个变量) - 更新段可以是 `i++`、`i--`、`i += 2`、`i *= 2` 等任意表达式,也可以省略 - 循环变量在子作用域中,循环结束后外部不可访问 - 循环体可以嵌套经典 for、for-in 循环以及任意其他模板语句块 - **注意:语言中没有 `break` 和 `continue` 语句**,因此条件表达式实际上不可省略——省略条件的 `{% for let i = 0; ; i++ }` 会成为无法退出的死循环 ### 7. Switch 语句块 支持在模板中使用 switch 语句块来根据变量的不同值选择不同的分支执行。Switch 语句块是标准模板语句块,有开始和结束标签。 #### 7.1 基本 switch 语句 **语法格式:** ``` {% switch 表达式 } {% case 值1 }文本内容1 {% case 值2 }文本内容2 {% default }默认文本内容 {/% switch } ``` **示例:** ``` {% switch status } {% case 1 }待处理 {% case 2 }处理中 {% case 3 }已完成 {% default }未知状态 {/% switch } ``` **说明:** - `switch` 后面跟一个表达式,用于判断匹配哪个分支 - 每个 `case` 后面跟一个值,当 switch 表达式的值等于 case 的值时,执行该分支 - `case` 和 `default` 是自闭合语句,以 `{%` 开始,以 `}` 结束 - `default` 分支是可选的,当所有 case 都不匹配时执行 - 匹配到某个 case 后,会执行该 case 的内容,然后自动结束(不需要 break) - case 的值支持所有类型的字面量:数字、字符串、布尔值等 #### 7.2 字符串匹配 **语法格式:** ``` {% switch 字符串表达式 } {% case '字符串1' }文本内容1 {% case '字符串2' }文本内容2 {% default }默认文本内容 {/% switch } ``` **示例:** ``` 会员等级: {% switch user.level } {% case 'diamond' }尊贵的钻石会员 {% case 'gold' }尊敬的黄金会员 {% case 'silver' }白银会员 {% case 'bronze' }青铜会员 {% default }普通会员 {/% switch } ``` **说明:** - 支持使用字符串进行匹配 - 字符串值需要用单引号或双引号包裹 - 字符串匹配区分大小写 - 适用于状态码、类型标识等字符串类型的分支判断 #### 7.3 表达式作为 case 值 **语法格式:** ``` {% switch 表达式 } {% case 表达式1 }文本内容1 {% case 表达式2 }文本内容2 {% default }默认文本内容 {/% switch } ``` **示例:** ``` {% switch score } {% case maxScore }满分! {% case maxScore - 10 }接近满分! {% case passingScore }刚好及格 {% default }普通分数 {/% switch } ``` **说明:** - case 后面可以是表达式,不仅限于字面量 - 支持使用变量、运算表达式等作为 case 的匹配值 - 表达式会在匹配时计算,然后与 switch 的值进行比较 #### 7.4 不带 default 的 switch **语法格式:** ``` {% switch 表达式 } {% case 值1 }文本内容1 {% case 值2 }文本内容2 {/% switch } ``` **示例:** ``` 特殊提示: {% switch vipLevel } {% case 10 }恭喜您是我们的最高等级会员! {% case 9 }您即将成为最高等级会员! {/% switch } ``` **说明:** - `default` 分支是可选的 - 如果没有 `default` 分支且所有 case 都不匹配,则不输出任何内容 - 适用于只需要处理特定值的场景 #### 7.5 嵌套 switch 语句 **语法格式:** ``` {% switch 表达式1 } {% case 值1 } {% switch 表达式2 } {% case 值2-1 }文本内容 {% case 值2-2 }文本内容 {/% switch } {% case 值2 }文本内容 {/% switch } ``` **示例:** ``` {% switch orderType } {% case 'online' } 在线订单 - {% switch paymentStatus } {% case 'paid' }已支付 {% case 'pending' }待支付 {% case 'cancelled' }已取消 {/% switch } {% case 'offline' } 线下订单 - {% switch deliveryStatus } {% case 'delivered' }已送达 {% case 'shipping' }配送中 {% case 'preparing' }准备中 {/% switch } {% default }未知订单类型 {/% switch } ``` **说明:** - switch 语句块(标准模板语句块)支持嵌套使用 - 内层 switch 可以访问外层的变量 - 嵌套深度没有限制,但建议不要超过 3 层以保持可读性 **完整示例:** ``` 订单状态详情: {% switch order.status } {% case 1 } 订单状态:待处理 预计处理时间:24小时内 {% case 2 } 订单状态:处理中 当前进度:{order.progress}% {% case 3 } 订单状态:已完成 完成时间:{order.completedTime} {% case 4 } 订单状态:已取消 取消原因:{order.cancelReason} {% default } 订单状态:未知 请联系客服查询 {/% switch } 用户权限: {% switch user.role } {% case 'admin' }管理员 - 拥有所有权限 {% case 'moderator' }版主 - 可以管理内容和用户 {% case 'vip' }VIP用户 - 享受高级功能 {% case 'user' }普通用户 {% default }游客 - 仅可浏览 {/% switch } ``` ### 8. 嵌入式代码块 支持在模板中使用嵌入式代码块来执行声明、赋值、导入等操作。嵌入式代码块是自闭合模板语句块,不需要结束标签。当 EasyTL 模板引擎渲染到嵌入式代码块时,会先执行代码块中的代码。 #### 8.1 变量声明和赋值 支持使用 `let` 关键字声明变量并赋值: **语法格式:** ``` {% let 变量名 = 表达式 %} ``` **示例:** ``` {% let name = 'EasyTL' %} {% let version = 1.0 %} {% let enabled = true %} 欢迎使用 {name} 版本 {version},状态:{enabled ? '启用' : '禁用'} ``` **说明:** - 使用 `let` 关键字声明变量 - 变量名遵循标识符命名规则 - 支持赋值任意类型的表达式:字符串、数字、布尔值、对象等 - 声明的变量在整个模板中可用 - 可以在后续的表达式和模板语句块中引用这些变量 - 这是一个自闭合模板语句块,以 `{%` 开始,以 `%}` 结束 #### 8.2 模板扩展 支持使用 `extends` 关键字扩展另一个模板文件: **语法格式:** ``` {% extends '模板文件路径' %} ``` **示例:** ``` {% extends 'layout.etl' %} {% extends 'components/header.etl' %} ``` **说明:** - 使用 `extends` 关键字引入其他模板文件 - 模板文件路径使用字符串字面量(单引号或双引号) - 被扩展的模板内容会在当前位置展开 - 扩展的模板可以访问当前上下文中的所有变量 - 适用于模板复用和组件化开发 - 这是一个自闭合模板语句块,以 `{%` 开始,以 `%}` 结束 #### 8.3 Java 类导入 支持使用 `import` 关键字导入 Java 类: **语法格式:** ``` {% import Java类全限定名 %} {% import Java类全限定名 as 别名 %} ``` **示例:** ``` {% import java.util.List %} {% import java.util.ArrayList %} {% import java.time.LocalDateTime %} {% import com.example.utils.StringUtils %} // 使用别名 {% import java.lang.Math as M %} {M.PI} // 输出 3.141592653589793 {M.sqrt(16)} // 输出 4.0 // 不使用别名时通过简单类名访问 {% import java.lang.Math %} {Math.abs(-1)} // 输出 1 ``` **说明:** - 使用 `import` 关键字导入 Java 类 - 需要提供类的全限定名(包名 + 类名) - `as 别名` 是可选的;不指定别名时使用类的简单名访问 - 导入后可以在模板中使用该类的静态方法和静态字段 - 导入的类可以通过 `类名(参数)` 构造实例,还支持 `类名 {属性: 值}` 的"构造 + 属性块"语法,详见 3.25 对象构造表达式(语言中没有 `new` 关键字) - 适用于在模板中使用 Java 工具类和自定义类 - 这是一个自闭合模板语句块,以 `{%` 开始,以 `%}` 结束 - 除模板内导入外,还可以通过 Java API 导入:`engine.importClass(X.class)`、`engine.importClass(X.class, "别名")`、`context.importClass(X.class)`、`TemplateBuilder.importClass(...)` 等 - 模板内的 `{% import %}` 语句可以通过引擎配置 `templateJavaClassImportEnabled(false)` 全局禁用(禁用后编译期报错);Java API 导入的类不受该开关影响——可信的 Java 代码显式导入的类始终可用,不可信的模板文本不能自行加载任意类 #### 8.4 多行代码块 支持在一个自闭合模板语句块中编写多条语句,语句之间使用换行符或分号分隔: **语法格式(换行符分隔):** ``` {% 语句1 语句2 语句3 %} ``` **语法格式(分号分隔):** ``` {% 语句1; 语句2; 语句3 %} ``` **示例(换行符分隔):** ``` {% let a = 1 let b = 2 let c = a + b * 2 %} 计算结果:a = {a}, b = {b}, c = {c} ``` **示例(分号分隔):** ``` {% let a = 1; let b = 2; let c = a + b * 2 %} 计算结果:a = {a}, b = {b}, c = {c} ``` **输出示例:** ``` 计算结果:a = 1, b = 2, c = 5 ``` **说明:** - 可以在 `{%` 和 `%}` 之间编写多行代码 - 多条语句之间可以用换行符分隔,也可以用分号分隔 - 支持的语句类型包括:变量声明、赋值、导入、扩展等 - 多行代码块中的所有语句会按顺序执行 - 声明的变量在代码块外部也可以访问 - 这是一个自闭合模板语句块,不需要结束标签 **完整示例:** ``` {% import java.time.LocalDateTime import java.text.SimpleDateFormat let title = 'EasyTL 模板引擎' let author = '开发团队' let version = '1.0.0' let year = 2024 %} # {title} **作者:** {author} **版本:** {version} **年份:** {year} --- {% let welcomeMessage = '欢迎使用 ' + title + ' ' + version %} {welcomeMessage} ``` **输出示例:** ``` # EasyTL 模板引擎 **作者:** 开发团队 **版本:** 1.0.0 **年份:** 2024 --- 欢迎使用 EasyTL 模板引擎 1.0.0 ``` (说明:与 if/for/switch 标准语句块不同,自闭合 `{% %}` 块所在行的换行符会保留在输出中,因此实际渲染结果在代码块位置会多出空行;此处为简洁省略展示。) #### 8.5 模板导出(export) 支持使用 `export` 关键字将模板的顶层变量导出,供其他模板导入使用。导出是实现模板间代码复用(如公共函数库)的基础。 **语法格式:** ``` {% export let 变量名 = 表达式 %} // 声明并导出 {% export 变量名 %} // 导出已声明的变量 ``` **示例(utils.etl):** ``` {% export let add = (a, b) -> a + b %} {% export let multiply = (x, y) -> x * y %} {% let config = {'name': 'utils', 'version': '1.0'} %} {% export config %} ``` **说明:** - 导出的内容通常是 Lambda 函数或哈希表(配置对象) - 导出的变量在当前模板内也可以正常使用 - 同一个变量重复导出会在编译期报错 - 导出需要配合模板导入(见 8.6)和模板加载器(见第 10 节)使用 #### 8.6 模板导入(import ... as ...) 支持使用 `import '模板路径' as 别名` 导入另一个模板导出的变量,实现模板级的代码复用。 **语法格式:** ``` {% import '模板路径' as 别名 %} ``` **说明:** - 路径必须是字符串字面量(单引号或双引号),`as 别名` 必填 - 路径是字符串时按模板导入解析(与 8.3 的 Java 类导入区分) - 路径相对模板加载器(TemplateLoader)的根位置解析,需要先在引擎上配置加载器 - 导入后通过 `别名.成员` 访问导出的值,通过 `别名.方法(参数)` 调用导出的 Lambda - 被导入模板采用懒加载:import 语句执行时只加载解析,首次访问成员时才执行并缓存导出值 - 被导入模板可以访问导入方的上下文变量 - 支持 `别名.render()` 渲染被导入模板的文本内容 - 模板不存在、模板语法错误、别名冲突都会在编译期报错;访问未导出的成员在运行时抛出 `TemplateRuntimeException` - 循环导入不会报错,但检测到循环时拿不到导出值(之后访问成员会报错) **示例:** 被导入的模板 utils.etl(内容同 8.5 的示例): ``` {% export let add = (a, b) -> a + b %} {% export let multiply = (x, y) -> x * y %} {% export config %} ``` 主模板: ``` {% import 'utils.etl' as utils %} 1 + 2 = {utils.add(1, 2)} 3 × 4 = {utils.multiply(3, 4)} 配置名称:{utils.config.name} ``` **输出:** ``` 1 + 2 = 3 3 × 4 = 12 配置名称:utils ``` ### 9. 动态变量(Dynamic Variable) EasyTL 支持动态变量功能,允许将 Java Lambda 表达式作为变量值,在模板访问时自动执行计算。 #### 9.1 基本用法 ```java import org.dromara.easytl.TemplateVariable; Context context = new Context(); context.put("name","World"); context.put("greeting", ctx -> "Hello, " + ctx.get("name")); Template template = engine.compile("Message: {greeting}"); String result = template.render(context); // 输出:Message: Hello, World ``` #### 9.2 访问其他变量 动态变量可以通过 Context 参数访问其他变量: ```java Context context = new Context(); context.put("user", userObject); context.put("displayName", ctx -> { User user = (User) ctx.get("user"); return user.getFirstName() + " " + user.getLastName(); }); Template template = engine.compile("欢迎, {displayName}!"); ``` #### 9.3 动态计算 动态变量适合需要实时计算的场景: ```java Context context = new Context(); context.put("items", orderItems); context.put("orderTotal", ctx -> { List items = (List) ctx.get("items"); int total = 0; for (OrderItem item : items) { total += item.getPrice() * item.getQuantity(); } return total; }); // 每次访问都会重新计算 Template template = engine.compile("订单总价: {orderTotal}元"); ``` #### 9.4 多次访问特性 每次访问动态变量都会重新执行 Lambda 表达式: ```java AtomicInteger counter = new AtomicInteger(0); context.put("counter", ctx -> counter.incrementAndGet()); Template template = engine.compile("第1次: {counter}, 第2次: {counter}, 第3次: {counter}"); String result = template.render(context); // 输出:第1次: 1, 第2次: 2, 第3次: 3 ``` #### 9.5 与语句块结合 动态变量可以与条件语句、循环语句结合使用: ```java context.put("user", user); context.put("isAdmin", ctx -> { User u = (User) ctx.get("user"); return u.getRole().equals("admin"); }); Template template = engine.compile("{% if isAdmin }管理员面板{/% if }"); ``` #### 9.6 异常处理 如果动态变量执行过程中抛出异常,会被包装为 `TemplateRuntimeException`: ```java context.put("errorVar", ctx -> { throw new RuntimeException("Error message"); }); try { template.render(context); } catch (TemplateRuntimeException e) { // 异常信息包含变量名 // Error computing dynamic variable 'errorVar' } ``` #### 9.7 getRaw 方法 如果需要获取动态变量的原始值(不执行 Lambda),可以使用 `getRaw` 方法: ```java context.put("dynamicVar", ctx -> "Computed Value"); // 获取原始 TemplateVariable 实例 Object rawValue = context.getRaw("dynamicVar"); assertTrue(rawValue instanceof TemplateVariable); // get() 方法会执行 Lambda assertEquals("Computed Value", context.get("dynamicVar")); ``` #### 9.8 线程安全 Context 使用 `ConcurrentHashMap` 存储变量,put/get/remove 操作是线程安全的。 但动态变量的 `compute` 方法由用户实现,用户需要自行确保其线程安全性。 ### 10. 模板加载器(TemplateLoader) `{% extends %}` 和 `{% import '...' as ... %}` 语句需要通过模板加载器定位外部模板文件。EasyTL 提供两个内置实现,也支持自定义。 #### 10.1 内置加载器 **FileTemplateLoader** — 从文件系统加载: ```java // 相对路径基于 basePath 解析,默认 UTF-8 FileTemplateLoader loader = new FileTemplateLoader("templates/"); // 也可以指定字符集 FileTemplateLoader loader2 = new FileTemplateLoader("templates/", StandardCharsets.UTF_8); ``` **ClasspathTemplateLoader** — 从 classpath 加载: ```java ClasspathTemplateLoader loader = new ClasspathTemplateLoader(); // 路径开头的 / 有无均可:'templates/base.etl' 与 '/templates/base.etl' 等价 ``` #### 10.2 配置到引擎 ```java // 方式一:显式传入加载器 TemplateEngine engine = new TemplateEngine( new TemplateEngine.TemplateEngineConfig(), new FileTemplateLoader("src/main/resources/templates") ); // 方式二:只配置模板根路径,引擎自动创建 FileTemplateLoader TemplateEngine.TemplateEngineConfig config = new TemplateEngine.TemplateEngineConfig() .setTemplateBasePath("templates/"); TemplateEngine engine = new TemplateEngine(config); // 方式三:通过 EngineBuilder 配置根路径 TemplateEngine engine = EasyTL.engine() .templateBasePath("templates/") .build(); ``` #### 10.3 自定义加载器 实现 `TemplateLoader` 接口即可: ```java public interface TemplateLoader { String load(String path); // 加载模板内容,失败抛 TemplateCompileException boolean exists(String path); // 判断模板是否存在 } ``` 自定义加载器只能通过 `new TemplateEngine(config, myLoader)` 传入。 **说明:** - 加载器同时服务于 `{% extends %}`、`{% import '...' as ... %}` 及其递归加载 - 未配置加载器时使用这两个语句会在运行时抛出 `TemplateRuntimeException` ### 11. URL 字面量与 URL 处理 EasyTL 内置 URL 构造语法 `url(...)`,支持内嵌表达式、按组件自动 URL 编码,并提供 `EtlURL` 对象进行组件级的读取和修改。 #### 11.1 url(...) 字面量 **语法格式:** ``` {url(URL文本)} ``` URL 文本中可以嵌入 `{表达式}`(也支持 `{{表达式}}`、`${表达式}` 形式),`\{`、`\}` 转义字面花括号: ``` {url(https://user:pass@host:8080/path?q=1#ref)} // 输出:https://user:pass@host:8080/path?q=1#ref // 设 host = 'example.com',v = 'x y' {url(http://{host}/path?a={v})} // 输出:http://example.com/path?a=x+y(query 值自动 URL 编码) // 相对 URL 形态同样支持 {url(/path/only)} {url(?a=1&b=2)} {url(#section)} {url(ws://host/path)} // 空构造合法,输出空串 {url()} ``` **说明:** - `url(...)` 是表达式级构造,只能在 `{...}` 表达式上下文中使用,纯文本中的 `url(` 原样输出 - 求值结果是 `EtlURL` 对象而不是字符串;模板输出时调用其 `toString()` - 表达式内可以直接取组件:`{url(http://example.com:8080/p?a=1).host}` 输出 `example.com` - 未定义的变量、值为 null 的变量安静渲染为空串:`{url(http://{missing}/p)}` 输出 `http:///p` - 内嵌表达式求值失败(如 null 成员访问)不会立即报错,而是在首次访问组件或输出时抛出带位置信息的 `TemplateRuntimeException` #### 11.2 EtlURL 组件 API `url(...)` 的结果支持读写 URL 的各个组件: - 组件读写:`get/setScheme`、`get/setUserInfo`、`get/setHost`、`get/setPort`、`get/setPath`、`get/setQuery`(整串)、`get/setRef` - Query 操作:`getQuery(key)`(单值,自动解码)、`getQueryValues(key)`(多值列表)、`addQuery(k, v)`(追加)、`setQuery(k, v)`(替换同名参数,不存在则追加)、`removeQuery(k)`、`clearQuery()` #### 11.3 Eval.evalAsURL Java 侧可以通过 `Eval.evalAsURL`(或 `EasyTL.evalAsURL`)直接构造 `EtlURL`,可省略 `url()` 包装: ```java import org.dromara.easytl.runtime.EtlURL; EtlURL u = Eval.evalAsURL("http://h/p?a=1&b=2", context); u.setQuery("a", "9"); // 替换已有参数 u.removeQuery("b"); // 删除参数 u.setRef("top"); u.toString(); // http://h/p?a=9#top ``` #### 11.4 懒求值特性 `EtlURL` 持有模板 AST 和 Context 引用,每次访问都重新构建——**Context 变量的后续修改会反映到 URL 上**;而通过 `setXxx()` 显式修改过的组件则被固定,不再随 Context 变化: ```java context.put("baseUrl", "https://api.example.com"); EtlURL u = Eval.evalAsURL("{baseUrl}/v1/users", context); u.toString(); // https://api.example.com/v1/users context.put("baseUrl", "http://localhost:8080"); u.toString(); // http://localhost:8080/v1/users u.setPort(9000); // 显式修改固定该组件 context.put("baseUrl", "http://another.com"); u.toString(); // http://another.com:9000/v1/users ``` #### 11.5 URL 编码器配置 URL 各组件的编码策略可以通过 Context 的编码器 setter 按组件定制: ```java import org.dromara.easytl.runtime.UrlEncoders; // 内置编码器:NOOP(不编码)、URL(form 编码)、BASE64、BASE64_URL_SAFE context.setUrlQueryValueEncoder(UrlEncoders.BASE64); // {url(http://h/p?q=abc)} 输出:http://h/p?q=YWJj context.setUrlQueryValueEncoder(UrlEncoders.NOOP); // {url(http://h/p?q=hello world)} 输出:http://h/p?q=hello world(不再编码) ``` 可用的 setter:`setUrlSchemeEncoder`、`setUrlUserInfoEncoder`、`setUrlHostEncoder`、`setUrlPathEncoder`、`setUrlQueryKeyEncoder`、`setUrlQueryValueEncoder`、`setUrlRefEncoder`。 **默认编码行为:** query 键/值、ref、host、scheme 默认走 form 编码(空格 → `+`,中文 → 百分号编码);userInfo、path 走保留 `:`、`/` 的安全编码。 #### 11.6 已知限制 - 带内嵌表达式时必须使用单花括号包装 `{url(...)}`;`{{url(...)}}`、`${url(...)}` 包装内嵌 `{表达式}` 会编译失败 - `{% let u = url(...) %}` 不可写(语句块模式下不识别 URL 字面量),需要修改 URL 的场景请使用 Java 侧的 `evalAsURL` - url() 内不支持嵌入 `{% %}` 语句块,也不能出现字面 `(` - IPv6 字面量 host(如 `[::1]`)会被默认编码破坏,使用前请为 host 组件配置 `UrlEncoders.NOOP` - userInfo 中包含 `@` 时组件拆分不准确 - path 中的空格会被编码为 `+` 而非 `%20` ### 12. 原文输出(@raw 语法) 除 `\{`、`\}` 转义外,EasyTL 还提供 `@raw` 语法,让一段内容完全原样输出、不做任何解析,适用于模板中需要大量输出 `{...}`、`{% %}`、`${...}` 等模板语法的场景(如生成模板文件本身、输出 JSON 示例等)。 #### 12.1 @raw{...} 块语法 `@raw{` 与匹配的 `}` 之间的内容原样输出,支持嵌套的平衡花括号: ``` Hello @raw{{name}}! // 输出:Hello {name}! @raw{RawText: {xxx}} // 输出:RawText: {xxx} // 嵌套花括号(要求括号平衡) @raw{a{b{}c}d} // 输出:a{b{}c}d // 块内的语句块语法同样原样输出 @raw{{% if x }content{/% if }} // 输出:{% if x }content{/% if } @raw{Price: ${price}} // 输出:Price: ${price} ``` **说明:** - 块内花括号必须平衡,未闭合的 `@raw{` 会在编译期抛出语法错误 - 空块 `@raw{}` 合法,输出空串 #### 12.2 @raw: 冒号语法 `@raw:` 之后到**模板末尾**的所有内容原样输出: ``` @raw: Everything after is raw {name} ${expr} // 输出: Everything after is raw {name} ${expr} // 语法之前的表达式仍正常解析 Hello {name}! @raw: Now raw: {xxx} // 输出(name=World):Hello World! Now raw: {xxx} ``` **说明:** - `@raw:` 的作用范围一直到模板结束,不是到行尾——它之后不能再写需要解析的内容 - 适用于模板尾部整段都是原文的场景 #### 12.3 EasyTL.raw() 工具方法 Java 侧拼接模板时,可以使用 `EasyTL.raw()` 给内容加上 `@raw:` 前缀使其原样输出: ```java String result = EasyTL.render("Hello " + EasyTL.raw("{name}"), data); // 输出:Hello {name} ``` ## 核心 API ### TemplateEngine 模板引擎主类,负责模板的编译和渲染。 ```java // 创建模板引擎实例 TemplateEngine engine = new TemplateEngine(); // 编译模板 Template template = engine.compile("Hello {user.name}!"); // 创建上下文 Context context = new Context(); context.put("user", userObject); // 渲染模板 String result = template.render(context); ``` **编译方法:** ```java engine.compile(String source); // 从字符串编译 engine.compile(String source, Map> imports); // 带 Java 类导入编译 engine.compile(InputStream in); // 从输入流编译(按配置的字符集解码) engine.compile(File file); // 从模板文件编译 ``` **Java 类导入(引擎级):** ```java engine.importClass(Math.class); // 模板中可直接使用 {Math.abs(-1)} engine.importClass(java.util.Date.class, "D"); // 别名导入,模板中使用 {D()} engine.getImportedClasses(); // 获取已导入的类(返回副本) ``` **模板编译缓存:** - 默认启用编译缓存(LRU),缓存键为模板源码字符串 - 相同源码 `compile()` 两次返回同一个 `Template` 实例 - 仅当模板长度不超过 1024 字符时才缓存 - `clearCache()` 清空缓存,`getCacheSize()` 获取当前缓存数量 ```java engine.clearCache(); int size = engine.getCacheSize(); ``` **引擎配置(TemplateEngineConfig):** | 配置项 | 默认值 | 说明 | | :--- | :--- | :--- | | `cacheEnabled` | `true` | 是否启用编译缓存 | | `maxCacheSize` | `100` | 缓存容量上限(LRU 淘汰) | | `templateJavaClassImportEnabled` | `true` | 是否允许模板内使用 `{% import %}` 导入 Java 类,置为 `false` 后模板内 import 在编译期报错(Java API 导入不受影响) | | `templateBasePath` | `""` | 模板根路径,非空时引擎自动创建 `FileTemplateLoader` | | `charset` | `UTF-8` | `compile(InputStream)` / `compile(File)` 及文件加载器的解码字符集 | ```java TemplateEngine.TemplateEngineConfig config = new TemplateEngine.TemplateEngineConfig() .setCacheEnabled(true) .setMaxCacheSize(500) .setTemplateBasePath("templates/") .setCharset(StandardCharsets.UTF_8) .setTemplateJavaClassImportEnabled(false); TemplateEngine engine = new TemplateEngine(config); // 也可以同时传入自定义模板加载器 TemplateEngine engine2 = new TemplateEngine(config, new ClasspathTemplateLoader()); ``` ### Template 编译后的模板对象。 ```java public interface Template { /** * 使用给定的上下文渲染模板 * @param context 上下文对象 * @return 渲染后的字符串 */ String render(Context context); } ``` ### Context 模板上下文,用于存储变量和对象。 ```java public class Context { /** * 添加变量到上下文 * @param name 变量名 * @param value 变量值 */ public void put(String name, Object value); /** * 从上下文获取变量 * @param name 变量名 * @return 变量值 */ public Object get(String name); } ``` **常用方法:** ```java context.put("name", "张三"); // 添加变量(支持 null 值) context.put("greeting", ctx -> "Hello"); // 添加动态变量(Lambda) context.get("name"); // 获取变量(动态变量会自动求值) context.getRaw("greeting"); // 获取原始值(不执行动态变量的 Lambda) context.contains("name"); // 判断变量是否存在 context.remove("name"); // 移除变量(仅当前作用域) context.clear(); // 清空当前作用域的所有变量 context.getVariables(); // 获取当前作用域的变量副本 ``` **位置参数(配合 `{0}` / `{$0}` 语法):** ```java context.setArguments("苹果", "香蕉"); // 设置位置参数 context.getArgument(0); // 获取参数(越界返回 null) context.getArguments(); // 获取全部参数 ``` **子作用域:** ```java Context child = context.createChildContext(); // 创建子作用域 // 子作用域可读父变量、可覆盖父变量,参数数组同样沿父链继承 Context parent = child.getParent(); ``` **Java 类导入(上下文级):** ```java context.importClass(Math.class); // 模板中可使用 {Math.sqrt(16)} context.importClass(java.util.Date.class, "D"); ``` **线程安全说明:** 变量存于 `ConcurrentHashMap`,put/get/remove 操作线程安全。但 `{i++}`、`{x += 1}` 等表达式会回写 Context,且 arguments 数组可变,因此多线程并发渲染时应各自使用独立的 Context。 ### Eval - 表达式求值 `Eval` 类提供了静态方法用于直接执行 EasyTL 表达式并返回结果,无需模板包装。 ```java import org.dromara.easytl.Eval; // 执行表达式并返回 Object Object result = Eval.eval("1 + 2"); // 返回 Integer 3 // 执行表达式并转换为指定类型 Integer sum = Eval.eval("1 + 2", Integer.class); // 返回 Integer 3 String str = Eval.eval("1 + 2", String.class); // 返回 String "3" // 便捷方法 Integer i = Eval.evalAsInteger("4 / 2"); // 返回 Integer 2 Long l = Eval.evalAsLong("42"); // 返回 Long 42L Float f = Eval.evalAsFloat("1.5"); // 返回 Float 1.5f Double d = Eval.evalAsDouble("1.5 + 1.5"); // 返回 Double 3.0 Boolean b = Eval.evalAsBoolean("1 < 2"); // 返回 Boolean true String s = Eval.evalAsString("'hello'"); // 返回 String "hello" List list = Eval.evalAsList("[1, 2, 3]"); // 返回 List Map map = Eval.evalAsMap("{\"a\": 1}"); // 返回 Map // URL 处理(可省略 url() 包装) EtlURL url = Eval.evalAsURL("http://example.com"); ``` ### EasyTL.eval() - 统一入口 `EasyTL` 类也提供了相同的 eval 方法,作为统一入口: ```java import org.dromara.easytl.EasyTL; // 基本使用 Object result = EasyTL.eval("1 + 2"); Integer sum = EasyTL.eval("1 + 2", Integer.class); // 便捷方法 Integer i = EasyTL.evalAsInteger("4 / 2"); Long l = EasyTL.evalAsLong("42"); String s = EasyTL.evalAsString("'hello'"); Boolean b = EasyTL.evalAsBoolean("1 < 2"); // 支持语句 Integer result = EasyTL.eval("let x = 1; let y = 2; x + y", Integer.class); // 返回 3 // 支持 return 语句 Integer value = EasyTL.eval("return 42", Integer.class); // 返回 42 ``` ### EasyTL 工具类方法一览 `EasyTL` 是 final 工具类,所有方法均为静态方法: **编译与渲染:** ```java EasyTL.template("Hello, {name}!"); // 创建 TemplateBuilder(链式 API) EasyTL.compile("Hello, {name}!"); // 编译模板 EasyTL.compile(engine, "Hello, {name}!"); // 使用指定引擎编译 EasyTL.render("Hello, {name}!", context); // 编译并渲染(Context) EasyTL.render("Hello, {name}!", map); // 编译并渲染(Map 数据) EasyTL.render(template, context); // 渲染已编译的模板 ``` **构建入口:** ```java EasyTL.engine(); // 创建 EngineBuilder EasyTL.config(); // 创建 ConfigBuilder EasyTL.context(); // 创建 ContextBuilder EasyTL.defaultEngine(); // 获取默认引擎(懒加载单例) ``` **辅助方法:** ```java EasyTL.raw("{name}"); // 加 @raw: 前缀,内容原样输出不解析 EasyTL.parse(source); // 解析模板并返回 AST 根节点 TemplateNode EasyTL.validate(source); // 校验模板语法,返回 ValidationResult ``` **ValidationResult:** ```java ValidationResult result = EasyTL.validate("Hello, {name"); result.isValid(); // false result.getErrorMessage(); // 语法错误描述 result.getErrorPosition(); // 错误位置(可能为 null) ``` **表达式求值(`Eval` 同名方法的直通转发):** ```java EasyTL.eval(source); EasyTL.eval(source, Integer.class); EasyTL.eval(source, context); EasyTL.eval(source, context, Integer.class); // 及 evalAsInteger / evalAsLong / evalAsFloat / evalAsDouble / // evalAsBigInteger / evalAsBigDecimal / evalAsBoolean / // evalAsString / evalAsList / evalAsMap / evalAsURL(均有带 Context 重载) ``` ### Builder API **TemplateBuilder(`EasyTL.template(source)` 创建):** ```java String result = EasyTL.template("Hello, {name}!") .put("name", "World") // 添加变量 .data(map) // 批量添加变量 .arguments("a", "b") // 设置位置参数 .importClass(Math.class) // 导入 Java 类 .templateJavaClassImportEnabled(false) // 禁用模板内 {% import %} .render(); // 编译并渲染 // 只编译不渲染(注意:compile() 不带入 put 的变量) Template template = EasyTL.template("Hello, {name}!").compile(); ``` **ContextBuilder(`EasyTL.context()` 创建):** ```java Context context = EasyTL.context() .put("name", "World") .put("greeting", ctx -> "Hello, " + ctx.get("name")) // 动态变量 .arguments("a", "b") .build(); ``` **EngineBuilder(`EasyTL.engine()` 创建):** ```java TemplateEngine engine = EasyTL.engine() .cacheEnabled(true) .maxCacheSize(500) .templateBasePath("templates/") .charset(StandardCharsets.UTF_8) .templateJavaClassImportEnabled(true) .importClass(Math.class) .importClass(java.util.Date.class, "D") .build(); ``` **ConfigBuilder(`EasyTL.config()` 创建):** 与 EngineBuilder 配置项相同(不含 importClass),产出 `TemplateEngineConfig` 供 `new TemplateEngine(config)` 使用: ```java TemplateEngine.TemplateEngineConfig config = EasyTL.config() .cacheEnabled(false) .build(); TemplateEngine engine = new TemplateEngine(config); ``` ### Eval 类型转换规则 `Eval.eval(source, targetType)` 及 `evalAsXxx` 方法的类型转换遵循以下规则(由 `TypeConverter` 实现): - 值为 null:目标类型是基本类型时抛出 `TypeConversionException`,否则返回 null - 目标类型为 String:任意类型按 `toString()` 转换 - 数值互转(int/long/double/float/byte/short 及包装类):double → int 为截断取整;向 int/byte/short 转换时做溢出检查,溢出抛出 `TypeConversionException` - 子类到父类(如 ArrayList → List)直接返回 - String → Integer 等不做解析(`eval("3.9", Integer.class)` 中的 3.9 是数字字面量所以可行;字符串 `'3.9'` 转 Integer 会抛出 `TypeConversionException`) - 例外:`eval(src, BigInteger.class)` / `eval(src, BigDecimal.class)` 会尝试解析字符串数字 ## 使用示例 ### 示例 1:基本变量替换 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile("你好,{name}!欢迎来到 {city}。"); Context context = new Context(); context.put("name", "张三"); context.put("city", "北京"); String result = template.render(context); // 输出:你好,张三!欢迎来到 北京。 ``` ### 示例 2:对象属性访问 ```java public class User { private String name; private int age; // getters and setters } TemplateEngine engine = new TemplateEngine(); Template template = engine.compile("用户信息:姓名={user.name},年龄={user.age}"); User user = new User(); user.setName("李四"); user.setAge(25); Context context = new Context(); context.put("user", user); String result = template.render(context); // 输出:用户信息:姓名=李四,年龄=25 ``` ### 示例 3:方法调用 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile("大写姓名:{user.getName().toUpperCase()}"); User user = new User(); user.setName("zhang san"); Context context = new Context(); context.put("user", user); String result = template.render(context); // 输出:大写姓名:ZHANG SAN ``` ### 示例 4:条件表达式 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile("状态:{age >= 18 ? '成年人' : '未成年人'}"); Context context = new Context(); context.put("age", 20); String result = template.render(context); // 输出:状态:成年人 ``` ### 示例 5:复杂表达式 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile( "订单总价:{order.getPrice() * order.getQuantity()}元," + "折扣后:{order.getPrice() * order.getQuantity() * 0.9}元" ); Order order = new Order(); order.setPrice(100.0); order.setQuantity(3); Context context = new Context(); context.put("order", order); String result = template.render(context); // 输出:订单总价:300.0元,折扣后:270.0元 ``` ### 示例 6:空安全操作符 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile("用户地址:{user?.address?.city}"); Context context = new Context(); // user 为 null 的情况 context.put("user", null); String result = template.render(context); // 输出:用户地址: // user 不为 null,但 address 为 null 的情况 User user = new User(); user.setAddress(null); context.put("user", user); result = template.render(context); // 输出:用户地址: // user 和 address 都不为 null 的情况 Address address = new Address(); address.setCity("上海"); user.setAddress(address); result = template.render(context); // 输出:用户地址:上海 ``` ### 示例 7:Elvis 表达式 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile("欢迎您,{name ?? '匿名用户'}!"); Context context = new Context(); // name 为 null 的情况 context.put("name", null); String result = template.render(context); // 输出:欢迎您,匿名用户! // name 有值的情况 context.put("name", "张三"); result = template.render(context); // 输出:欢迎您,张三! ``` ### 示例 8:空安全与 Elvis 表达式组合 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile( "用户信息:{user?.name ?? '匿名用户'}," + "邮箱:{user?.email ?? '未设置'}," + "城市:{user?.address?.city ?? '未知'}" ); Context context = new Context(); // user 为 null 的情况 context.put("user", null); String result = template.render(context); // 输出:用户信息:匿名用户,邮箱:未设置,城市:未知 // user 有部分信息的情况 User user = new User(); user.setName("李四"); user.setEmail(null); user.setAddress(null); context.put("user", user); result = template.render(context); // 输出:用户信息:李四,邮箱:未设置,城市:未知 ``` ### 示例 9:正则表达式验证 ```java TemplateEngine engine = new TemplateEngine(); // 邮箱验证 Template emailTemplate = engine.compile( "邮箱格式:{email =~ /^[\\w.-]+@[\\w.-]+\\.\\w+$/ ? '有效' : '无效'}" ); Context context = new Context(); context.put("email", "user@example.com"); String result = emailTemplate.render(context); // 输出:邮箱格式:有效 context.put("email", "invalid-email"); result = emailTemplate.render(context); // 输出:邮箱格式:无效 // 手机号验证 Template phoneTemplate = engine.compile( "手机号:{phone =~ /^1[3-9]\\d{9}$/ ? '正确' : '错误'}" ); context.put("phone", "13812345678"); result = phoneTemplate.render(context); // 输出:手机号:正确 // 用户名验证(不能是纯数字) Template usernameTemplate = engine.compile( "用户名:{username !=~ /^\\d+$/ ? '可用' : '不可用(不能为纯数字)'}" ); context.put("username", "user123"); result = usernameTemplate.render(context); // 输出:用户名:可用 context.put("username", "123456"); result = usernameTemplate.render(context); // 输出:用户名:不可用(不能为纯数字) ``` ### 示例 10:正则表达式组合验证 ```java TemplateEngine engine = new TemplateEngine(); Template template = engine.compile( "密码强度:" + "{password =~ /^.{8,}$/ && " + " password =~ /[A-Z]/ && " + " password =~ /[a-z]/ && " + " password =~ /\\d/ ? '强' : '弱'}" ); Context context = new Context(); // 强密码 context.put("password", "Abc12345"); String result = template.render(context); // 输出:密码强度:强 // 弱密码 context.put("password", "abc123"); result = template.render(context); // 输出:密码强度:弱 // 禁止特定用户名 Template userCheckTemplate = engine.compile( "注册结果:" + "{username !=~ /^(admin|root|system)$/i ? '注册成功' : '该用户名已被保留'}" ); context.put("username", "admin"); result = userCheckTemplate.render(context); // 输出:注册结果:该用户名已被保留 context.put("username", "john"); result = userCheckTemplate.render(context); // 输出:注册结果:注册成功 ``` ## 错误处理 ### 语法错误 当模板语法错误时,编译阶段会抛出 `TemplateCompileException`: ```java try { Template template = engine.compile("Hello {user.name"); // 缺少右花括号 } catch (TemplateCompileException e) { System.err.println("模板语法错误:" + e.getMessage()); } ``` ### 运行时错误 当表达式执行出错时,渲染阶段会抛出 `TemplateRuntimeException`: ```java try { String result = template.render(context); } catch (TemplateRuntimeException e) { System.err.println("模板执行错误:" + e.getMessage()); } ``` ## 性能特点 - **编译一次,多次渲染**:模板编译后可以重复使用,提高性能 - **无反射优化**:核心表达式执行尽量减少反射调用 - **内存友好**:使用对象池减少对象创建开销 - **线程安全**:Template 对象线程安全,可以在多线程环境下共享 ## 设计原则 1. **零依赖**:不依赖任何第三方库,保持轻量级 2. **简单易用**:API 设计简洁,学习成本低 3. **性能优先**:编译后的模板执行效率高 4. **安全可控**:支持沙箱模式,限制危险操作 5. **可扩展性**:支持自定义函数和运算符扩展 ## Maven 依赖 ```xml com.github.easytl easy-tl 1.0.0 ``` ## 项目结构 ``` easy-tl/ ├── src/ │ ├── main/ │ │ └── java/ │ │ └── com/ │ │ └── github/ │ │ └── easytl/ │ │ ├── TemplateEngine.java # 模板引擎主类 │ │ ├── Template.java # 模板接口 │ │ ├── Context.java # 上下文类 │ │ ├── exception/ # 异常类 │ │ ├── parser/ # 词法和语法解析器 │ │ ├── ast/ # 抽象语法树节点 │ │ ├── compiler/ # 编译器 │ │ └── runtime/ # 运行时执行器 │ └── test/ │ └── java/ │ └── com/ │ └── github/ │ └── easytl/ │ └── ... # 单元测试 ├── pom.xml ├── README.md └── PLAN.md ``` ## 常见问题 (FAQ) ### Q1: EasyTL 与其他模板引擎(如 FreeMarker、Thymeleaf)有什么区别? **A:** EasyTL 的主要特点: - **零依赖**:不依赖任何第三方库,体积小巧 - **类 JavaScript 语法**:表达式语法类似 JavaScript,学习成本低 - **轻量级**:专注于字符串模板处理,不绑定 Web 框架 - **高性能**:编译一次,多次渲染,支持线程安全 适合场景:配置文件生成、SQL 模板、邮件模板、消息模板等轻量级字符串处理场景。 ### Q2: EasyTL 支持哪些表达式语法? **A:** EasyTL 支持三种表达式嵌入语法,功能完全等价: - `{expression}` - 单花括号语法 - `{{expression}}` - 双花括号语法 - `${expression}` - 美元符号语法 选择建议: - 如果模板中包含 JSON 数据,推荐使用 `${expression}` 或 `{{expression}}` 避免与 JSON 花括号冲突 - 如果模板中包含 JavaScript 代码,推荐使用 `{expression}` 避免与模板字符串语法冲突 ### Q3: 如何处理 null 值? **A:** EasyTL 提供两种空安全机制: **空安全访问符 `?.`**:安全访问可能为 null 的对象属性 ```java {user?.name} // 如果 user 为 null,渲染为空串而不报错 {user?.address?.city} // 链式空安全访问 ``` **Elvis 运算符 `??`**:提供默认值 ```java {name ?? '匿名用户'} // 如果 name 为 null,输出 '匿名用户' {user?.name ?? '未知'} // 空安全与 Elvis 组合使用 ``` ### Q4: 模板编译后可以重复使用吗? **A:** 是的。`Template` 对象是线程安全的,编译后可以缓存起来多次使用: ```java // 编译一次 Template template = engine.compile("Hello, {name}!"); // 多次渲染 for (User user : users) { Context context = new Context(); context.put("name", user.getName()); String result = template.render(context); } ``` ### Q5: 如何在模板中调用 Java 方法? **A:** EasyTL 支持直接在表达式中调用对象的方法: ```java // 调用 getter 方法 {user.getName()} // 调用带参数的方法 {str.substring(0, 5)} // 调用静态方法(需要先导入) {% import java.lang.Math %} {Math.abs(-10)} ``` ### Q6: 支持哪些运算符? **A:** EasyTL 支持丰富的运算符: | 类型 | 运算符 | |------|--------| | 算术运算符 | `+`, `-`, `*`, `/`, `%` | | 比较运算符 | `==`, `!=`, `<`, `>`, `<=`, `>=` | | 逻辑运算符 | `&&`, `\|\|`, `!` | | 三元运算符 | `condition ? value1 : value2` | | Elvis 运算符 | `??` | | 空安全运算符 | `?.` | | 正则匹配 | `=~`, `!=~` | | 范围运算符 | `..`, `..<`, `>..`, `>..<` | | 包含运算符 | `in`, `!in` | | 集合操作符 | `subsetof`, `anyof`, `noneof` | | 自增自减 | `++i`, `i++`, `--i`, `i--` | | 复合赋值 | `+=`, `-=`, `*=`, `/=`, `%=`, `&=`, `\|=`, `^=`, `<<=`, `>>=`, `>>>=` | | 类型转换 | `(int) x`, `(String) x` 等 | 注意:语言中没有二元的位运算/移位运算符(`&`、`|`、`^`、`<<`、`>>`),位运算只以复合赋值形式存在;也没有 `break`、`continue` 和幂运算符。 ### Q7: 如何处理模板中的特殊字符? **A:** **转义表达式语法**: ```java // 输出字面量 {name} 而不是解析它 \{name\} // 输出:{name} ``` **字符串中的引号**: ```java {'It\'s a test'} // 使用反斜杠转义 {"He said \"hello\""} ``` **模板字符串(反引号)**: ```java {`多行 文本 内容`} ``` ### Q8: 如何实现模板继承/复用? **A:** EasyTL 提供两种模板复用机制,都需要先配置模板加载器(见第 10 节): **`{% extends %}` — 内容内联展开:** 被引入的模板使用当前上下文渲染,结果插入到 `extends` 语句所在位置;当前模板自身的内容照常输出(EasyTL 没有 block/override 式的布局继承机制): ```java // header.etl 内容:
{title}
// page.etl 内容: {% extends 'header.etl' %}
{content}
// 渲染结果:
标题
//
正文
``` **`{% export %}` + `{% import '...' as ... %}` — 函数/变量复用:** 将公共 Lambda 函数、配置对象导出,在其他模板中导入使用: ```java // utils.etl {% export let formatPrice = (p) -> '¥' + p %} // page.etl {% import 'utils.etl' as utils %} 价格:{utils.formatPrice(99.9)} // 渲染结果:价格:¥99.9 ``` ### Q9: 支持哪些控制流语句? **A:** EasyTL 支持完整的控制流语句: **条件语句**: - `{% if condition }...{/% if }` - if 语句 - `{% if condition }...{% else }...{/% if }` - if-else 语句 - `{% switch expr }...{/% switch }` - switch 语句 **循环语句**: - `{% for item in list }...{/% for }` - 集合遍历 - `{% for i in 1..10 }...{/% for }` - 区间循环 - `{% for let i = 0; i < n; i++ }...{/% for }` - 经典三段式循环 ### Q10: 出现错误如何排查? **A:** EasyTL 提供详细的错误信息: **语法错误**(编译阶段): ```java try { Template template = engine.compile("Hello {name"); // 缺少右括号 } catch (TemplateCompileException e) { // 错误信息包含位置和原因 System.err.println(e.getMessage()); } ``` **运行时错误**(渲染阶段): ```java try { String result = template.render(context); } catch (TemplateRuntimeException e) { // 错误信息包含表达式和执行位置 System.err.println(e.getMessage()); } ``` ### Q11: 性能如何?适合生产环境吗? **A:** EasyTL 针对性能进行了优化: - 编译后的模板可重复使用,避免重复解析 - `Template` 对象线程安全,可在多线程环境共享 - 核心表达式执行减少反射调用 - 支持编译缓存,自动缓存已编译的模板 建议: - 对常用模板进行预编译并缓存 `Template` 对象 - 避免在循环中重复编译相同的模板字符串 - 使用 `TemplateEngineConfig` 配置合适的缓存大小 ### Q12: 可以在模板中使用 Java 类吗? **A:** 可以。使用 `import` 关键字导入 Java 类: ```java {% import java.time.LocalDateTime %} {% import java.text.SimpleDateFormat %} {% import java.util.Date %} 当前时间:{LocalDateTime.now()} {% let formatter = SimpleDateFormat('yyyy-MM-dd') %} 格式化日期:{formatter.format(Date())} ``` ### Q13: 如何在模板中定义变量? **A:** 使用 `let` 关键字: ```java {% let name = 'EasyTL' %} {% let version = 1.0 %} {% let count = list.size() %} 项目:{name},版本:{version},数量:{count} ``` ### Q14: EasyTL 的设计目标是什么? **A:** EasyTL 的设计原则: 1. **零依赖**:不依赖任何第三方库 2. **简单易用**:API 设计简洁,学习成本低 3. **性能优先**:编译后的模板执行效率高 4. **安全可控**:支持沙箱模式,限制危险操作 5. **可扩展性**:支持自定义函数和运算符扩展 ### Q15: 在哪里可以找到更多示例? **A:** 您可以通过以下方式获取更多示例: - 查看 `examples/` 目录下的示例代码 - 阅读本文档的「功能特性」和「使用示例」章节 - 参考项目单元测试代码(`src/test/java/`) ### Q16: 如何在模板中定义和复用函数? **A:** 使用 Lambda 表达式(箭头函数)定义函数,配合 `export`/`import` 在模板间复用: **模板内定义函数(Lambda):** ``` {% let formatPrice = (p) -> '¥' + p %} {% let max = (a, b) -> a > b ? a : b %} 价格:{formatPrice(99.9)} // 输出:价格:¥99.9 较大值:{max(3, 7)} // 输出:较大值:7 ``` **跨模板复用函数:** ``` // utils.etl — 导出函数 {% export let formatPrice = (p) -> '¥' + p %} // 其他模板 — 导入使用(需配置模板加载器) {% import 'utils.etl' as utils %} {utils.formatPrice(99.9)} ``` 详见「3.21 Lambda 表达式」和「8.5/8.6 模板导出与导入」。 ## 许可证 MIT License ## 联系方式 如有问题或建议,欢迎提 Issue 或 PR。