珠宝定制设计
57.77MB · 2025-11-24
Google Mug库是我维护的一款开源Java工具库。包含了一些近几年在Google内部的labs代码库中被广泛使用的工具,集成了一些经实践验证很成功也比较成熟了的新工具。
今天我先介绍Mug的StringFormat库。
这个库的初衷是为了解决很多很常见的从字符串中抽取信息的问题。比如,某个文件名会是这样一个人格式 /usrs/{user}/logs/{year}/{month}/{day}/{name}.log。那么,给定一个这个格式的文件名,怎么从中抽取这些占位符对应的值呢?
传统上,大家会用正则来处理这种信息抽取。
private static final Pattern LOG_FILE_PATTERN = Pattern.compile(
"/usrs/(?<user>[^/]+)/logs/(?<year>d{4})/(?<month>d{2})/(?<day>d{2})/(?<name>.+).log");
Matcher matcher = LOG_FILE_PATTERN.matcher(filePath);
if (matcher.matches()) {
String user = matcher.group("user");
String year = matcher.group("year");
String month = matcher.group("month");
String day = matcher.group("day");
String name = matcher.group("name");
...
}
这样做的好处是:正则嘛,大家都会。坏处呢,正则表达式往往可读性较差,在Java里写有时候是两个反斜线还是四个反斜线也容易搞混了。对复杂的匹配规则,这么做是值得的,但是对上面这种常见的格式固定的抽取,就显得杀鸡用牛刀,代码维护起来就会难一些。
另外,为了效率,正则的Pattern 对象往往要定义成static final来一次性编译regex。但是带来的问题是pattern和具体parse的代码可能会分开得比较远(比如隔上几个翻页)。这样写 group("name")这种的时候, 你可能要上滚去找具体的组名字,如果写错了组名字,或者有时候图省事都不用命名capture group,直接用魔法数索引,编译也不会报错;读代码的时候,尤其是调试的时候,也可能要上下滚动对照pattern和底下的抽取代码的一致性。
还有一个问题一般人可能不会在意,但是如果你的代码要跑在高可用性,高吞吐量的服务器上的话,regex其实是有稳定性的缺陷的。Java的regex实现用的是NFA+回溯,这种实现的特点是它可能对大多数输入都很快,但是对某些特殊输入,或者恶意的regex-dos攻击,可能会造成指数级的“灾难回溯”。真实的例子:
这大概算一个80-20问题。对80%的简单但普遍的情况,Google Mug的StringFormat 是一个更方便更安全高效的工具。这个抽取可以用以下代码直观和简单地做到:
private static final StringFormat LOG_FILE_FORMAT =
new StringFormat("/usrs/{user}/logs/{year}/{month}/{day}/{name}.log");
LOG_FILE_FORMAT.parse(filePath, (user, year, month, day, name) -> ...);
它直接用我们上面最直观的日常用到的带占位符的格式串,然后直接抽取。返回的是一个Optional<T>,这样就如果格式不匹配就显式返回空,帮助使用者不会忘记处理失败情况。
或者如果你知道这个格式肯定匹配,那么就用 parseOrThrow()。
这么做的好处有:
(year, month, day, user, name), 编译器会报错。 这就让你可以放心地把StringFormat定义成static final,然后在别的地方重用而不需担心一致性问题。String.indexOf(), 一般比regex要更高效,也没有回溯问题。多说一句。因为对服务器可靠性的考量(还记得前几天的Google全球宕机吗?虽然那个是C++ UB的锅,但是可靠性是大型互联网公司都无法忽视的普遍问题),Google内部已经原则上禁用JDK的regex,因为NFA虽然对平均情况的性能不错,但是遇到某些特殊的输入甚至恶意攻击可能会指数级回溯。
目前谷歌的替代品是用JNI包裹了一个C++的RE2的实现。但是benchmark跑下来,在JNI的边界传递输入输出的代价高昂,所以比如你的输入字符串很大,或者你要用regex来做抽取,效率都不高。
我现在在写一个静态分析,帮助把一些本来没必要用regex的用例迁移到StringFormat或者是Substring上(后者是一个比Apache StringUtils更灵活更强大可读性更好的字符串工具类,支持链式调用的)。比如,"^projects/(?<project>[^/]+)/locations/(?<location>[^/]+)/jobs/(?<job>[^/]+)$" 这种蛋疼的regex完全可以写成:
new StringFormat("`projects/{project}/locations/{location}/jobs/{job}`")
.parseOrThrow(input, (project, location, job) -> ...);
高效,易读,没有灾难性回溯。
前面的示例是完整匹配后抽取。你也可以用 scan()方法来实现在字符串里寻找符合这个格式的子串。比如以下代码扫描markdown文件,找到所有的链接:
List<MarkdownLink> links = new StringFormat("[{title}]({url})")
.scan(markdown, (title, url) -> new MarkdownLink(title, url))
.toList();
scan()返回的是一个懒加载的Stream<T>,所以你也可以比如用findFirst(), limit(n),anyMatch()来中途退出而不用付全字符串扫描的代价。
StringFormat代替String.format()StringFormat是个双向的API。除了抽取,还支持格式化字符串,支持 format(Object...)方法。
上面提到的编译期插件也用在了format()。比如:
String logFile = LOG_FILE_FORMAT.format(user, year, month, day, name);
跟抽取类似,如果你把参数的个数或者顺序写错了,编译器会报错。
对比JDK的String.format(), 如果你有一个格式串要多次使用,那么你可能想要把它定义为 static final 。但是这样一来,在调用String.format() 的时候,就有风险把参数顺序和个数搞错,造成逻辑错误。
而用StringFormat就没有这个问题了。你可以放心地复用private static final的StringFormat常量。从谷歌内部代码情况来看,用StringFormat来做格式化比做抽取还要常见。
你也可以做所谓的rewrite。比如,如果要把user的部分改名字,就可以做:
Map<String, String> renamings = ...;
String newFile = LOG_FILE_FORMAT.parseOrThrow(
filePath,
(user, year, month, day, name) ->
LOG_FILE_FORMAT.format(
renamings.get(user), year, month, day, name)));
最后,运行效率上,Java 17以前的String.format()内部用的是正则表达式去parse这个格式串,效率相当低。换用StringFormat.format()后据benchmark大约有几十倍的提升。即使是Java 17之后,StringFormat(预分配成static final的话)也比JDK的快5倍左右。