把 URI 模板编译成正则:转义、分组与优先级
自己写的路由把
/files/{name}.txt编译成正则,一个请求/files/reportXtxt也被它接住了。另一个模板/orders/{id: (a|b)\d+}/{part}取出来的part总是一个字母。还有一次,两个模板都能匹配/users/me,结果交给了带变量的那个。
路由框架、网关、权限规则里经常要把 {name} 形式的路径模板变成正则表达式来匹配请求。这件事看上去只是一次字符串替换,实际包含四个容易出错的步骤:模板里的字面字符要转义,变量要能被可靠地取出,构造正则时替换串有自己的语法,多个模板都能匹配时要按确定的规则选一个。
本文用 JDK 25 对比朴素实现、改正后的实现和 Jersey 4.0.3 的 UriTemplate,逐个复现这几个问题。实验见文末。正则在不同语言里的差异见同一个正则在三种语言里结果不同,转义的层次见反斜杠数了三遍。
一、字面部分要转义
朴素的做法是把模板里的 {name} 换成 ([^/]+?),其余字符原样拼进正则:
/files/{name}.txt 朴素编译后匹配 /files/reportXtxt:name=report
字面部分 Pattern.quote 后匹配 /files/reportXtxt:不匹配;匹配 /files/report.txt:name=report
Jersey UriTemplate 匹配 /files/reportXtxt:不匹配;匹配 /files/report.txt:name=report模板里的 . 在正则里是「任意字符」。路径里常见的 .、+、(、$ 都是正则元字符,字面部分必须用 Pattern.quote 或逐字符转义后再拼接。在路由里这只是匹配范围变宽;用在权限规则里(「/admin/{x}.json 需要管理员」)就可能让本应拦截的路径漏过去,或者让不该放行的路径被放行。
二、变量的值要按名字取,不能按位置
模板允许为变量指定正则,例如 {id: (a|b)\d+}。如果按变量出现的顺序取第 1 组、第 2 组:
/orders/{id: (a|b)\d+}/{part} 朴素按出现顺序取组,匹配 /orders/a12/items:id=a12 part=a
改用命名组:id=a12 part=items
Jersey UriTemplate:id=a12 part=items变量自己的正则里有一个捕获组 (a|b),它在整个正则里占了第 2 组,part 的值被挤到了第 3 组。解决办法是给每个变量用命名组 (?<id>...),或者在编译时统计每个变量正则里的捕获组数量,记录每个变量对应的组号。对用户提供的子正则,最好要求或改写为非捕获组 (?:...)。
用户能提供正则时还有一个安全问题:嵌套量词之类的写法会引发回溯爆炸,见正则回溯。模板的子正则应该来自开发者,而不是请求方。
三、构造正则时,替换串也是一种语言
用 String.replaceAll 把模板里的占位符替换成别的内容时,第二个参数不是普通字符串:
用 replaceAll 把 {v} 换成 US$5:IndexOutOfBoundsException:No group 5
替换串先 Matcher.quoteReplacement:price=US$5
替换值是 C:\tmp(字符串里一个反斜杠):replaceAll 得到「path=C:tmp」,quoteReplacement 后得到「path=C:\tmp」替换串里 $n 表示第 n 组的引用,\ 用来转义下一个字符。值来自配置或数据时,必须先 Matcher.quoteReplacement。这和第一节是同一类问题:数据进入了另一种语法的上下文,就要按那种语法转义。
四、多个模板都匹配时,按规则排序
按 Jersey UriTemplate.COMPARATOR 排序:[/users/me, /users/{id}/{tab}, /users/{id: \d+}, /users/{id}, /{any: .*}]
/users/me 由 /users/me 处理
/users/42 由 /users/{id: \d+} 处理
/users/abc 由 /users/{id} 处理
/users/42/posts 由 /users/{id}/{tab} 处理
/static/a.css 由 /{any: .*} 处理Jakarta REST 规范规定的排序键依次是:字面字符数(多的优先)、捕获组数(多的优先)、使用非默认正则的捕获组数(多的优先)。直觉上就是「越具体越优先」:纯字面的 /users/me 胜过带变量的 /users/{id},带约束的 {id: \d+} 胜过不带约束的 {id},兜底的 /{any: .*} 排在最后。
如果随手按模板字符串的长度排序:
按模板字符串长度降序排序:[/users/{id}/{tab}, /users/{id: \d+}, /users/{id}, /{any: .*}, /users/me];/users/me 由 /users/{id} 处理/users/{id} 比 /users/me 长,/users/me 这个请求就被带变量的模板接走了。排序规则必须表达「具体程度」,而不是任意一个看起来相关的数字。
五、比较器要满足契约
排序用的比较器必须满足 Comparator 的契约:sgn(compare(a, b)) == -sgn(compare(b, a)),以及传递性。一个常见的错误写法是「从不返回 0」:
检查 sgn(compare(a,b)) == -sgn(compare(b,a)):「长度大于等于就返回 -1」的比较器在 25 对里违反 5 对(都是元素与自身比较:compare(a,a) 返回 -1 而不是 0),Jersey 的比较器违反 0 对违反契约的比较器在元素少时往往看不出问题,元素多时 List.sort 使用的 TimSort 可能抛出 Comparison method violates its general contract,或者给出依赖输入顺序的结果。多键比较应该用 Comparator.comparing(...).thenComparing(...) 组合,每个键都是一个全序;同分时还要有一个确定的决胜键,否则「哪个模板优先」取决于注册顺序。
每个排序键各写一组只在这个键上不同的模板做测试,比一个大而混杂的样例更容易看出是哪条规则出了问题。
六、检查清单
- 模板的字面部分用
Pattern.quote转义。 - 变量用命名组取值;用户提供的子正则改为非捕获组,并限制来源。
- 替换值经过
Matcher.quoteReplacement。 - 候选模板按「具体程度」排序,排序键与规范一致,同分有确定的决胜规则。
- 比较器满足反对称与传递性,用组合比较器而不是手写分支。
配套实验
- codesphere-labs/engineering/uri-template-regex:JDK 25.0.4.1、Jersey 4.0.3:字面转义、捕获组错位、替换串转义、模板优先级与比较器契约(验证记录)
参考资料
- Jakarta RESTful Web Services 4.0 规范:3.7.2 Request Matching、3.7.3 Converting URI Templates to Regular Expressions
- Javadoc:java.util.regex.Pattern#quote、Matcher#quoteReplacement
- Javadoc:java.util.Comparator
- RFC 6570:URI Template