Google Mug库——一个现代的通用工具库
Google Mug库是我维护的一款开源Java工具库。包含了一些近几年在Google内部的labs代码库中被广泛使用的工具集成了一些经实践验证很成功也比较成熟了的新工具。今天我先介绍Mug的StringFormat库。这个库的初衷是为了解决很多很常见的从字符串中抽取信息的问题。比如某个文件名会是这样一个人格式 /usrs/{user}/logs/{year}/{month}/{day}/{name}.log。那么给定一个这个格式的文件名怎么从中抽取这些占位符对应的值呢为什么不用正则表达式传统上大家会用正则来处理这种信息抽取。javaprivate 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 是一个更方便更安全高效的工具。这个抽取可以用以下代码直观和简单地做到javaprivate 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) - ...);它直接用我们上面最直观的日常用到的带占位符的格式串然后直接抽取。返回的是一个OptionalT这样就如果格式不匹配就显式返回空帮助使用者不会忘记处理失败情况。或者如果你知道这个格式肯定匹配那么就用 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完全可以写成:javanew StringFormat(projects/{project}/locations/{location}/jobs/{job}).parseOrThrow(input, (project, location, job) - ...);高效易读没有灾难性回溯。多次抽取前面的示例是完整匹配后抽取。你也可以用 scan()方法来实现在字符串里寻找符合这个格式的子串。比如以下代码扫描markdown文件找到所有的链接java体验AI代码助手代码解读复制代码ListMarkdownLink links new StringFormat([{title}]({url})).scan(markdown, (title, url) - new MarkdownLink(title, url)).toList();scan()返回的是一个懒加载的StreamT所以你也可以比如用findFirst(), limit(n)anyMatch()来中途退出而不用付全字符串扫描的代价。用StringFormat代替String.format()StringFormat是个双向的API。除了抽取还支持格式化字符串支持 format(Object...)方法。上面提到的编译期插件也用在了format()。比如java体验AI代码助手代码解读复制代码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的部分改名字就可以做javaMapString, 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倍左右。原文链接https://juejin.cn/post/7554322871418814499