# 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