实用工具

这里总结了一些比较实用的工具类。

树状数据工具

从 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>。

结构约束与孤立节点

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("决定书二〇〇一年十二月二十一日的");

支持的输入包括:

从 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);