实用工具
这里总结了一些比较实用的工具类。
树状数据工具
从 rabbit-common 3.2.15 起,提供 com.github.chengyuxing.common.tree.Tree,用于将扁平数据组织为树,并执行查找、增删改、移动和筛选。它不依赖 UI、数据库或业务模型。
定义节点与构建
节点实现 TreeNode<T, ID>,其中 T 是节点自身类型,ID 是主键类型。例如 TreeNode<Node, Long>:
import com.github.chengyuxing.common.tree.Tree;
import com.github.chengyuxing.common.tree.TreeNode;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
class Node implements TreeNode<Node, Long> {
private Long id;
private Long pid;
private List<Node> children;
private Node parent;
private String label;
public Node() {}
public Node(Long id, Long pid, String label) {
this.id = id;
this.pid = pid;
this.label = label;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getPid() { return pid; }
public void setPid(Long pid) { this.pid = pid; }
public List<Node> getChildren() { return children; }
public void setChildren(List<Node> children) { this.children = children; }
public Node getParent() { return parent; }
public void setParent(Node parent) { this.parent = parent; }
public String getLabel() { return label; }
}
Tree<Node, Long> tree = new Tree<>(Arrays.asList(
new Node(1L, null, "产品"),
new Node(2L, 1L, "数据库"),
new Node(3L, 2L, "Rabbit SQL"),
new Node(4L, 1L, "其他")
));
List<Node> roots = tree.getRoots();
Node node = tree.getNode(3L); // 不存在时返回 null
List<Node> path = tree.getPath(3L); // [1, 2, 3],从最高已知祖先到自身
List<Long> ids = tree.search(n -> n.getLabel().contains("SQL"));
tree.updateNode(3L, Collections.singletonMap("label", "Rabbit SQL 文档"));
Tree<Node, Long> filtered = tree.filterTree(n -> n.getId().equals(3L));
// filtered 保留 1、2、3,排除 4;原树不变。
tree.moveNode(2L, null); // 2 连同后代 3 移到根列表末尾
List<Node> flat = tree.toFlat(); // 当前树按根优先顺序展开,清除父子引用
Java 版只有 pid == null 表示根节点,0 和空字符串均可作为普通 ID。构建时浅复制输入节点及继承的实例字段,忽略输入中的 children、parent,再根据 id、pid 建立关系。默认节点类需要无参构造器;也可通过 new Tree<>(flatNodes, copier) 提供复制函数,每次返回保留业务字段的新节点。
移动与筛选
moveNode(id, pid) 将节点及其子树追加到目标父节点的子列表末尾,pid 为 null 时追加到根列表末尾。节点实例及索引顺序保持不变;移动到当前父节点时不改变位置。源节点不存在、目标父节点不存在,或目标是自身/后代时抛出异常,校验失败不修改树。
filterTree(predicate) 对每个索引节点按索引顺序调用一次条件函数,返回只包含命中节点及其已知祖先的新树。父节点命中不会自动保留后代,这一点与 searchWithContext 不同,后者返回命中节点、祖先和后代的 ID。
筛选保留当前根节点、兄弟节点顺序,包括移动后的顺序;新树按遍历顺序建立索引。结果节点重新复制并建立内部父子引用,因此移动、删除结果中的节点不会改变原树。业务字段仍遵循浅复制规则,嵌套业务对象可能被共享。
文档站前端的数据工具 src/app/common/tree.ts 同样提供 moveNode(id, pid) 和 filterTree(predicate),上述移动、筛选行为与 Java 版一致。前端使用 DataNode<T, ID>,pid 为 null 或 undefined 都表示根节点;Java 使用 TreeNode<T, ID>。
结构约束与孤立节点
- Java 版构建时拒绝空节点、空 ID、重复 ID、自指和父链成环。
addNode(pid, node)支持携带子树,并在修改前检查目标父节点、重复 ID 和子树结构。 - Java 版
updateNode只更新已有的业务实例字段,字段值须与类型兼容;禁止修改id、pid、children、parent。变更父节点使用moveNode。 - 缺失父节点的分支会保留在索引中,不自动成为根节点。
getSize()、getNode()、search()和filterTree()会包含这些节点;getRoots()和toFlat()只从根节点开始,因此树可能有索引节点但isEmpty()仍为true。筛选后的孤立分支继续保持这一规则,可用moveNode接回树中。 getAncestors从直接父节点向上返回;getPath从最高已知祖先返回到自身;getDescendants返回全部后代。getSiblings包含自身所在的兄弟列表,孤立节点沿用根列表规则;getSibling支持NEXT、PREVIOUS、FIRST、LAST。removeNode(id)连同后代一起从结构和索引中移除。查询返回的节点、根列表及子列表属于当前可变树,直接修改结构字段不会自动维护索引,应使用 Tree 提供的方法。该类不是线程安全的。
SQL 字符串高亮工具
com.github.chengyuxing.sql.util.SqlHighlighter
直接生成 ANSI 高亮,或根据 System.console() 和 TERM 判断是否启用终端配色:
String sql = "select :id, '/* 普通内容 */';\n";
String colored = SqlHighlighter.ansi(sql);
String consoleText = SqlHighlighter.highlightIfAnsiCapable(sql);
高亮按原始 SQL 片段拼接,保留分号、空白、换行和字符串内容。字符串、双引号或反引号标识符、普通注释中的 :name 不会被标记为 SQL 参数;支持重复引号转义、跨行字符串、PostgreSQL E'...' 转义字符串和嵌套块注释。
PostgreSQL $$...$$、$tag$...$tag$ 作为完整字符串着色,分隔符紧贴正文也能识别。它们内部的 SQL、注释和参数文本不会再次进行 SQL 高亮。RabbitScript 指令、元数据和内联模版注释仍保留内部表达式配色。
高亮只处理显示,不校验 SQL 合法性。未闭合的引号、注释和 dollar quoted 片段会保留原文;自定义回调抛出异常时,方法返回完整原 SQL。
自定义输出
使用三参数方法定制识别到的片段:
String highlight(String sql,
Function<String, String> commentStyleCleaner,
BiFunction<SqlHighlighter.TAG, String, String> replacer);
commentStyleCleaner 在分类注释前移除已有样式,例如使用 Printer::removeStyle;原始纯文本也可使用 Function.identity()。replacer 接收词法标签和原文片段,未识别为词法片段的空白及符号原样保留。
String original = SqlHighlighter.highlight(sql, Function.identity(),
(tag, content) -> content);
// original 与 sql 完全一致
主要标签包括 KEYWORD、FUNCTION、NUMBER、QUOTE_STRING、NAMED_PARAMETER、ASTERISK、LINE_COMMENT、BLOCK_COMMENT,以及三种特殊注释标签 RABBIT_SCRIPT_COMMENT、METADATA_DEFINE_COMMENT、INLINE_TEMPLATE_COMMENT。
NAMED_PARAMETER 的回调内容不包含前导冒号,例如 :user.id 对应 user.id;冒号由高亮器原样保留。dollar quoted 片段整体使用 QUOTE_STRING,不再按 POSTGRESQL_FUNCTION_BODY_SYMBOL 分别处理其分隔符。
元组
com.github.chengyuxing.common.tuple
框架内提供了一元组到十元组,通过静态类 Tuples.of(...) 来快捷使用:
DataRow
继承自 LinkedHashMap<String, Object> 的一个实用数据类,提供了一些常用方法。
传入不定长的键值对数据来构建一个 Map:
DataRow of(Object... input);
将实体转为 Map :
DataRow ofEntity(Object entity);
获取一个值并进行转换,如果值为 null ,则依次选择候选者直到不为 null。
根据键名:
<T> T getAs(String name, T... defaults);
按顺序取值:
<T> T getAs(int index, T... defaults);
强制转换类型:
String getString(String name, String... defaults);
挑出一些 key 来生成一新的 DataRow:
DataRow pick(String name, String... more);
转为实体:
<T> T toEntity(@NotNull Class<T> clazz, Object... constructorParameters)
日期时间工具
com.github.chengyuxing.common.MostDateTime
完整解析与文本提取
parse(String) 校验完整输入,适合数据库值和业务参数;非法日期、未识别的前后缀及多余的小数秒位数会报错。of(String) 保留从文本中提取日期的能力:
MostDateTime value = MostDateTime.parse("2026-10-09T12:34:56.123456789Z");
MostDateTime extracted = MostDateTime.of("决定书二〇〇一年十二月二十一日的");
支持的输入包括:
- 13 位毫秒时间戳、10 位秒时间戳。
- 紧凑格式
yyyyMMddHHmmssSSS、yyyyMMddHHmmss、yyyyMMdd。 - 普通日期,例如
2026-10-09、2026/10/09、2026.10.09、2026年10月9日。 - 普通日期时间,例如
2026-10-09 12:34:56.1234、2026年10月9日 12时34分56秒。 - 中文日期,例如
二〇二六年六月二十六日。 - ISO,例如
2026-10-09T12:34:56.1Z、2019-09-26T03:45:36.656+0800,标准格式也支持区域时区。 - SQL 风格的带偏移时间,例如
2026-10-10 10:35:57.672+08、2026-10-10 10:35:57.672+0800、2026-10-10 10:35:57.672+08:30;秒数可省略,偏移量前可有一个空格,例如2026-10-10 10:35 +08。 - RFC_1123,例如
Wed, 04 Jan 2023 09:36:48 GMT。 - RFC-like,例如
Wed Jan 04 2023 17:36:48 GMT+0800、Wed Jan 04 18:52:01 CST 2023。
从 rabbit-common 3.2.14 起,上述 SQL 风格的偏移时间在完整解析和文本提取中都保留显式偏移量。+08:30 不会被截成 +08,RFC-like GMT 文本中的偏移也使用相同修复。以 Instant、Date 或 Timestamp 表示时刻时,显式偏移不受服务器默认时区影响:
String source = "2026-10-10 10:35:57.672+08";
MostDateTime.parse(source).toInstant(); // 2026-10-10T02:35:57.672Z
ValueUtils.adaptValue(Instant.class, source); // 同一个时刻
MostDateTime.of("created at 2026-10-10 10:35:57.672+08:30 (record)")
.toInstant(); // 2026-10-10T02:05:57.672Z
parse 已统一兼容已知格式,但仍检查完整输入;ValueUtils.adaptValue 不会在解析失败后退回 of,避免将带错误尾部的数据当作转换成功。of 用于显式的文本提取,适合业务自行处理描述性文本。
小数秒支持 1~9 位,按小数位数补齐纳秒,例如 .1234 对应 123400000 纳秒。紧凑日期和指定格式严格校验,不会把 20260230 自动修正成月底。
指定格式使用 of(datetime, pattern),按完整输入解析,保留格式中的偏移或区域时区;引号中的格式字母按字面量处理:
MostDateTime utc = MostDateTime.of("2026-10-09 12:34:56 +00:00",
"yyyy-MM-dd HH:mm:ss XXX");
MostDateTime literal = MostDateTime.of("2026-10-09 H", "yyyy-MM-dd 'H'");
时区和缺省日期
of(Temporal) 保留输入已有的时区或偏移;无时区的本地值使用系统默认时区。of(Temporal, ZoneId) 将带时区的输入转换到目标时区,保留同一个时刻;本地日期和时间在目标时区解释:
OffsetDateTime source = OffsetDateTime.parse("2026-10-09T12:34:56+08:00");
MostDateTime utc = MostDateTime.of(source, ZoneId.of("UTC"));
// utc.toInstant() 为 2026-10-09T04:34:56Z
纯时间统一以其所属时区的今天为日期,纯时间字符串使用系统默认时区;缺少年份使用当前年份。RFC-like 中的 CST 有歧义,为兼容原有解析规则仍按系统默认时区解释;需要确定时区时使用明确偏移或区域时区。
of(Date, ZoneId) 按源对象的 epoch 时间值转换,支持 JDBC 日期子类;Timestamp 保留纳秒。SQL 日期转 LocalDate、SQL 时间转 LocalTime 时,使用 ValueUtils 的对应类型转换以保留日历值。
转换、运算和格式化
转换方法均在实例上调用,不接收日期字符串:
| 方法 | 返回类型与含义 |
|---|---|
toLocalDateTime() |
LocalDateTime,本地日期时间,不包含时区 |
getZonedDateTime() |
ZonedDateTime,保留区域时区或偏移 |
toLocalDate() |
LocalDate,日期部分 |
toLocalTime() |
LocalTime,时间部分 |
toInstant() |
Instant,时间线上的绝对时刻 |
toDate() |
普通 java.util.Date,毫秒精度 |
toEpochMilli() |
epoch 毫秒时间值 |
MostDateTime next = value.plus(1, ChronoUnit.DAYS);
MostDateTime previous = value.minus(30, ChronoUnit.MINUTES);
String formatted = value.toString("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
旧的无参 toZonedDateTime() 实际返回 LocalDateTime,已更名为 toLocalDateTime(),旧调用需要修改;带时区结果使用 getZonedDateTime()。静态方法 MostDateTime.toZonedDateTime(String) 仍提供文本提取,验证完整字符串使用 parse(String)。
字符串模板格式化工具
com.github.chengyuxing.common.StringFormatter
格式化带有模板占位参数的字符串:
String format(String template, Map<String, ?> data);
例如:
select ${ fields } from test.user
where ${ cnd}
and id in (${!idArr})
or id = ${!idArr.1}
变量:
{
fields: "id, name",
cnd: "name = 'cyx'",
idArr: ["a", "b", "c"]
}
结果:
select id, name from test.user
where name = 'cyx'
and id in ('a', 'b', 'c')
or id = 'b'
未检查自动关闭接口
com.github.chengyuxing.common.UncheckedCloseable
从里到外依次对实现了 Closeable 的对象进行自动关闭。
构造一个对象:
UncheckedCloseable wrap(AutoCloseable closeable);
继续嵌套:
UncheckedCloseable nest(AutoCloseable closeable);
对象值工具
com.github.chengyuxing.common.util.ValueUtils
枚举类型适配(3.2.16 起)
Status state = ValueUtils.adaptValue(Status.class, "ACTIVE");
Status first = ValueUtils.adaptValue(Status.class, 0);
字符串必须与枚举常量名称完全一致,不使用 toString(),不忽略大小写或空白。数字按从 0 开始的序号转换,必须为整数且在枚举范围内;数字字符串 "0" 不作序号解释。未知名称、小数、越界序号及其他不兼容类型会报错;null 和同类型枚举对象保持不变。DataRow.toEntity 及使用 ValueUtils.adaptValue 的元数据提供者可复用这项能力。
UUID 类型适配
从 rabbit-common 3.2.15 起,支持标准 36 位 UUID 字符串及无横线的 32 位十六进制字符串:
UUID id = ValueUtils.adaptValue(UUID.class, "550e8400-e29b-41d4-a716-446655440000");
UUID same = ValueUtils.adaptValue(UUID.class, "550e8400e29b41d4a716446655440000");
// id.equals(same) == true
支持大小写,已有 UUID 对象直接返回,null 保持为 null。空串、非法十六进制字符、错误长度、缩短或超长分组会抛出 IllegalArgumentException;不自动去除首尾空格。
DataRow.toEntity() 可直接映射 UUID 属性;自定义实体提供者可在 columnValue 中复用 ValueUtils.adaptValue(field.getType(), value)。这只负责查询值转换,SQL 参数的存储格式见 配置项,旧数据处理见 升级与迁移。
日期和时间类型适配
Date date = ValueUtils.adaptValue(Date.class, java.sql.Date.valueOf("2026-10-09"));
Instant instant = date.toInstant();
LocalDate localDate = ValueUtils.adaptValue(LocalDate.class,
java.sql.Date.valueOf("2026-10-09"));
转换规则如下:
| 目标类型或转换 | 行为 |
|---|---|
SQL 日期子类 → java.util.Date |
复制成普通 Date,保留毫秒值,避免运行时仍为 SQL 日期子类 |
| 显式 SQL 日期类型 | 保留或转换到 java.sql.Date、Time、Timestamp 对应类型 |
java.sql.Date → LocalDate |
保留日期日历值,不按其他时区移动日期 |
java.sql.Time → LocalTime |
保留时间日历值 |
| 日期对象 → 其他支持的 Java 时间类型 | 按 epoch 时间值和时区转换;Timestamp 保留纳秒 |
| 字符串 → 日期或 Java 时间类型 | 使用 MostDateTime.parse(String) 校验完整输入 |
toTemporal(targetType, date, zoneId) 可指定转换时区;不传 zoneId 的重载使用系统默认时区。支持 LocalDateTime、ZonedDateTime、OffsetDateTime、LocalDate、LocalTime、OffsetTime、Instant。原值为 null 时,adaptValue 返回 null。
普通 Date 只能保存毫秒。字符串或 Timestamp 转到 Timestamp、Instant、LocalDateTime 等支持纳秒的类型时保留支持的精度。实体自定义值转换可直接调用 ValueUtils.adaptValue(field.getType(), value),详见 实体兼容 JPA 等其他框架。
其他对象值工具
平铺 IF-ELSE-ELSE-IF-ELSE 值比较返回满足的值,效果类似 Oracle 的 decode 函数:
Object decode(Object value, Object equal, Object result, Object... more);
从一组候选者中获取第一个不为 null 的值:
<T> T coalesce(T... values);
如果2个值相等则返回 null :
Object nullif(Object a, Object b);
使用路径表达式 /a/b/0/name 获取一个深度嵌套对象值:
Object walkDeepValue(Object obj, @NotNull String path);
使用对象表达式 user.name 获取一个深度嵌套对象值:
Object getDeepValue(Object obj, @NotNull String propertyChains);
反射工具
com.github.chengyuxing.common.util.ReflectUtils
判断对象是否是基本的值类型(包括包装类):
boolean isBasicType(Object value);
根据构造函数参数个数和类型获取一个类的实例:
<T> T getInstance(Class<T> clazz, Object... constructorParameters);
泛型返回类型解析(3.2.16 起)
按从 0 开始的索引,获取方法返回类型中的一个泛型参数:
// 方法签名:Map<String, User> findUsers();
Class<?> keyType = ReflectUtils.getReturnGenericType(method, 0); // String.class
Class<?> valueType = ReflectUtils.getReturnGenericType(method, 1); // User.class
List<Map<String, Object>> 的第 0 个参数返回 Map.class;List<String[]> 返回 String[].class。该工具直接使用反射提供的类型对象,无需指定 ClassLoader,不重新按类名加载,也不初始化实体类。
非参数化返回类型(例如 String 或原始 List)返回 null。负索引、超出参数化类型参数数量的索引抛出 IndexOutOfBoundsException;选中的参数是通配符、未确定的类型变量或泛型数组时抛出 IllegalArgumentException,并包含方法及参数信息。解析一个参数时不会要求其他参数也能解析。
XQL Mapper 的 protected getReturnGenericType(Method) 默认调用索引 0,子类仍可重写;工具支持读取多个泛型参数,并不意味着 Mapper 自动支持任意泛型返回结构的组装。
Bean 候选类型判断(3.2.16 起)
boolean candidate = ReflectUtils.isBindableObject(type);
用于初步判断是否适合按 JavaBean 属性读取或映射。排除 java.* / javax.* / jakarta.* 类型、接口、基本类型、数组、枚举及枚举常量的子类、合成类和 JDK 动态代理;也排除 Number、CharSequence、Date、Calendar、Map、Iterable 的业务子类。
返回 true 只表示候选类型,不保证存在 getter、setter 或可用构造器。读取已存在的参数对象不需要无参构造器;创建查询结果实体时,由实际映射路径检查构造器与实例化能力,并保留异常原因。
字符串工具
com.github.chengyuxing.common.util.StringUtils
使用带有分隔符捕获组的正则表达式对字符串进行分割,返回分割后的字符串集合和分隔符集合:
Pair<List<String>, List<String>> regexSplit(final String s, @Language("Regexp") final String regex, final String groupName);
替换所有匹配的项,并返回替换后的字符串和被替换的内容集合:
graph LR
A["Hello world"] --H|w--> B["#$0#"];
B --> C["#H#ello #w#orld"]
B --> D["H, w"]
Pair<String, List<String>> replaceAll(final String s, @Language("Regexp") final String regex, final String replacement);
判断是否以一组关键字任意一个开头:
boolean startsWiths(String str, String... keywords);
不区分大小写的 indexOf 方法:
int indexOfIgnoreCase(String source, String target);
摘要算法:
String hash(String content, String algorithm);
文件读取工具
com.github.chengyuxing.common.io.FileResource
路径支持 URI 和 Classpath 下的资源:
FileResource(@NotNull String path);
- ClassPath:
sql/rabbit.sql - URI:
- Windows:
file:/D:/rabbit.sql - Linux/Unix:
file:/root/rabbit.sql - HTTP(S):
http(s)://host/rabbit.sql - FTP:
ftp://username:password@ftp.example.com/path/rabbit.sql
- Windows: