Google Mug库——一个现代的通用工具库

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()

这么做的好处有:

  1. 格式串直观可读。
  2. 抽取部分代码简洁,不需要依赖魔法数,没有组名字写错的风险。
  3. 库自带ErrorProne的编译期插件,如果你在lambda里,把参数顺序搞错了,比如 (year, month, day, user, name), 编译器会报错 。 这就让你可以放心地把StringFormat定义成static final,然后在别的地方重用而不需担心一致性问题。
  4. 在运行时,它用的是简单的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倍左右。

相关推荐
SimonKing2 小时前
弃用html2canvas!新一代截图神器snapdom要快800倍
java·后端·程序员
迷迷的k3 小时前
云服务器 + Jenkins 实现项目自动化部署与上线
java·运维·自动化·jenkins
bjdnlsj3 小时前
【MAC环境】安装多个 JDK
java·开发语言·macos
RainbowSea3 小时前
6. Advisor 对话拦截
java·spring·ai编程
间彧3 小时前
lock.isHeldByCurrentThread详解与应用
java
小小王app小程序开发3 小时前
废品回收小程序:从 “扔垃圾“ 到 “变资源“ 的体验革命
java·开发语言·小程序
间彧3 小时前
在多线程调试中,如何结合isHeldByCurrentThread()方法快速定位死锁问题?
java
没有bug.的程序员3 小时前
ShardingSphere 与分库分表:分布式数据库中间件实战指南
java·数据库·分布式·中间件·分布式数据库·shardingsphere·分库分表
麦兜*4 小时前
Redis监控告警体系搭建:使用Redis Exporter + Prometheus + Grafana
java·spring boot·redis·spring·spring cloud·grafana·prometheus