Google Mug库是我维护的一款开源Java工具库。包含了一些近几年在Google内部的labs代码库中被广泛使用的工具,集成了一些经实践验证很成功也比较成熟了的新工具。
今天我先介绍Mug的StringFormat
库。
这个库的初衷是为了解决很多很常见的从字符串中抽取信息的问题。比如,某个文件名会是这样一个人格式 /usrs/{user}/logs/{year}/{month}/{day}/{name}.log
。那么,给定一个这个格式的文件名,怎么从中抽取这些占位符对应的值呢?
为什么不用正则表达式?
传统上,大家会用正则来处理这种信息抽取。
java
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攻击,可能会造成指数级的"灾难回溯"。真实的例子:
- Stack Overflow 2016 : a regex used to extract comment anchors caused a global outage due to backtracking explosion (postmortem).
- Cloudflare 2019 : 一个有问题的regex造成cpu超负载,大量服务器宕机 (incident report).
用StringFormat抽取格式化信息
这大概算一个80-20问题。对80%的简单但普遍的情况,Google Mug的StringFormat 是一个更方便更安全高效的工具。这个抽取可以用以下代码直观和简单地做到:
java
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()
。
这么做的好处有:
- 格式串直观可读。
- 抽取部分代码简洁,不需要依赖魔法数,没有组名字写错的风险。
- 库自带ErrorProne的编译期插件,如果你在lambda里,把参数顺序搞错了,比如
(year, month, day, user, name)
, 编译器会报错 。 这就让你可以放心地把StringFormat定义成static final
,然后在别的地方重用而不需担心一致性问题。 - 在运行时,它用的是简单的
String.indexOf()
, 一般比regex要更高效,也没有回溯问题。
禁用NFA
多说一句。因为对服务器可靠性的考量(还记得前几天的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完全可以写成:
java
new StringFormat("`projects/{project}/locations/{location}/jobs/{job}`")
.parseOrThrow(input, (project, location, job) -> ...);
高效,易读,没有灾难性回溯。
多次抽取
前面的示例是完整匹配后抽取。你也可以用 scan()
方法来实现在字符串里寻找符合这个格式的子串。比如以下代码扫描markdown文件,找到所有的链接:
java
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()
。比如:
java
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
的部分改名字,就可以做:
java
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倍左右。