同一段商品文本会进入不同语法

商品名称既可能显示在页面正文,也可能放进 HTML 属性、JSON 接口和 XML 导出。另一个需求是将模板中的商品编号替换为实际值,或者找出拼写相近的编号。这些任务都接收字符串,却不共享一种“安全处理”方法:输出编码取决于目的语法,插值取决于允许访问的数据,相似度计算取决于输入规模。

本篇固定 Commons Text 1.15.0,提交 04e937470d3679cc163df85d82d5b6d2e3e71128。实验在 JDK 8 与 JDK 21 上使用同一组输入,只访问内存中的合成商品数据,不读取真实环境变量或文件。完整代码见 Chapter27Test.java,执行方式见 RUN.md。

业务契约分成三个可验证条件:文本按照最终输出位置编码;模板只能查询明确提供的键,未知键报错,值中的占位符不递归展开;距离计算先限制输入长度,再判断是否超过距离阈值。缺少其中任一条件,都可能出现输出语法被改变、模板获得额外能力或单次计算成本失控的问题。

HTML 正文的转义结果不能直接用于所有属性

实验把 <b>&"' 交给 escapeHtml4,结果为 &lt;b&gt;&amp;&quot;'。尖括号、与号和双引号变化了,单引号仍然存在。这个结果符合 HTML 4 实体集合的实现,却不能支持“所有引号都被处理”的推断。

如果输出位置是单引号包围的属性,保留下来的单引号可以改变属性边界。即便改用双引号,事件属性、URL 属性和普通文本属性的语义也不同。把某段值写入 onclick 或 href 时,仅验证 HTML 语法字符并不能决定脚本或 URL 是否允许执行。属性应由模板或 DOM API 按既定类型设置,URL 还需要独立的协议政策。

StringEscapeUtils 固定源码为 HTML、JSON、ECMAScript 和 XML 建立不同的 translator。它们负责字符转换,没有一个通用方法能够推断调用方准备把结果放进哪种语法位置。调用方必须先确定上下文,再选择相应工具。

目的位置 本组实验能证明的行为 仍需独立处理的条件
HTML 正文 escapeHtml4 转换尖括号与与号 不应将结果再当成标签结构
单引号 HTML 属性 单引号没有被 escapeHtml4 转换 使用上下文适配的属性输出方式
JSON 字符串内容 双引号和换行转换为 JSON 转义 完整对象应由 JSON 序列化器构造
JavaScript 字符串 escapeEcmaScript 处理单引号 还要考虑所在 HTML 容器
XML 1.0 文本 实验中的 NUL 被删除 数据丢失政策与完整文档合法性

JSON 字符串与内联脚本之间还有一层解析

escapeJson 对双引号加换行的输入得到反斜杠引号和反斜杠 n。这个断言检查字符串内部编码,没有构造完整 JSON 文档。把转义后的内容直接拼到未加引号的 JSON 位置,仍会产生错误;因此,实际接口优先交给 JSON 序列化器处理对象结构。

实验也检查 escapeJson("</script>") 的结果仍包含小于号。这个断言的用途是阻止把 JSON 编码误认为 HTML 容器编码。将 JSON 嵌入 script 元素时,浏览器首先处理 HTML 层的结束标签边界,之后才轮到 JavaScript 或 JSON 内容。数据在某层合法,不表示跨过外层解析器仍然安全。

同理,ECMAScript 的字符串转义并不等于整段 JavaScript 程序验证。它不知道结果将出现在字符串、模板字面量、注释还是可执行表达式中。需要传递结构化数据时,避免手工拼接可执行脚本,通常比不断补充字符替换规则更容易验证。

XML 的限制还涉及可表示字符集合。实验输入 a、NUL、b,escapeXml10 输出 ab;非法控制字符被移除,并没有保留为某种可逆形式。对于商品标识或签名原文,这种变化可能不可接受,应用应在编码前拒绝原始数据或选择明确的二进制表示。对于展示文本是否允许删去控制字符,也应成为显式业务政策。

第一项可迁移模式是按解析层分别定义输入和输出。数据跨越 HTML、脚本、JSON 三层时,每层都有自己的结构字符和合法值范围。方法名中出现 escape 只能证明进行了某种转换,不能代替最终位置的契约。

插值器的能力由 lookup 决定

商品标签模板只需要 sku 和 label。测试直接将这两个键放入 Map,再用 new StringSubstitutor(values) 创建实例。这个构造方式将查询限制在提供的映射中,没有启用通用 interpolator,也没有登记文件、系统属性或环境变量查询器。

模板 item=${sku} 得到 item=SKU-1。${env:SYNTHETIC_ONLY} 被当作映射中不存在的键,随后抛出异常;实验没有执行一次真实的环境查询来证明拒绝。这里的证据是所使用的 lookup 类型、提供的键集合以及未知键的失败结果。

StringSubstitutor 固定源码将变量解析与替换流程分开。系统是否具备某种查找能力,取决于配置的 resolver,而不只取决于模板里有没有看起来危险的前缀。先开放广泛查询能力,再尝试用字符串黑名单屏蔽,较难完整解释可接受范围。

本例设置 enableUndefinedVariableException 为 true。未知键将中止生成,而不是把残留占位符作为成功结果交给下游。这适合需要完整配置的商品导出;如果模板编辑器允许暂时保留缺失变量,应明确区分“预览未完成”和“导出已完成”,不要共用一个含糊的成功标志。

递归替换与变量名替换是不同开关

还需关闭默认值分隔符。上游测试表明,存在 ${unknown:-fallback} 这类默认值时,未知变量异常开关本身不会拒绝默认替代。本例额外将 valueDelimiterMatcher 设为 null,并断言这个输入同样失败,避免“未知键必须报错”的政策被默认值语法绕过。

映射中的 label 值为 ${sku}。默认递归替换时,${label} 最终得到 SKU-1;将 disableSubstitutionInValues 设为 true 后,结果保持 ${sku}。两者都查找了 label,但后者不会继续解释取得的值。

这一区别对不可信商品描述尤其重要。描述中出现 ${...},可能只是用户希望显示的文本,不应自动取得模板语言的语义。把模板结构与商品值分开,能够避免数据在第二轮处理时变成指令。测试还将 enableSubstitutionInVariables 设为 false,使变量名自身不通过嵌套替换动态生成。

循环也是单独一种失败路径。映射 cycle 指向 ${cycle},默认递归实例抛出 IllegalStateException。检测直接循环不等于限制所有输出膨胀:无环模板也可以重复引用长值,产生很大的字符串。因此,即使禁止递归,入口模板长度、单值长度、变量数量与最终输出长度仍应按业务预算控制。

本组三个测试没有实现完整的流式有界模板引擎,也没有证明超大替换结果在分配前被拦截。输出长度后验检查只能拒绝交付,不能撤销已经发生的内存分配。需要强内存预算时,必须选用能边生成边计数的方案,或通过输入总量与引用次数事先给出可证明的上界。

第二项可迁移模式是先收窄能力,再规定失败语义。Map 白名单限定可以访问什么,未知键异常限定不完整结果如何处理,禁用值递归限定数据是否继续被解释。三个条件互相补充,任何一个都不能自动推出另外两个成立。

相似度阈值没有限制输入长度

商品编号纠错可以使用 LevenshteinDistance。阈值为 2 时,SKU-1 与 SKU-2 的距离为 1;abcd 与 wxyz 的真实距离超过阈值,方法返回 -1。这里的 -1 表示超过阈值,不是负距离,也不能直接进入“距离越小排名越靠前”的排序逻辑。

LevenshteinDistance 固定源码包含有阈值和无阈值的计算路径。有阈值实现缩小动态规划中需要计算的区域,但仍需读取输入、处理长度,并执行与输入有关的操作。阈值限定可接受编辑距离,没有把任意长度输入变成恒定成本。

测试的 boundedDistance 先比较两个字符串的 length,超过 maxChars 就抛异常,再调用阈值为 2 的距离算法。maxlength 为 3 时,abcd 被入口拒绝;abc 与 abd 得到 1。这个简单次序把资源政策放在算法之前,而不是希望算法返回 -1 时顺便承担输入限制。

Java String.length 计数的是 UTF-16 代码单元。对补充平面字符、组合字符和人眼看到的字形,代码单元长度未必等于字符数量。本例处理 ASCII 商品编号,因此长度预算与业务字符数一致;如果改为自然语言名称,需要另外决定正规化、大小写、代码点或字素边界,不能把编号实验推广为完整的多语言文本匹配规则。

JDK 替代与验收范围

距离结果进入排序之前还需要定义候选集合。若每个请求把一百万个编号逐个计算一遍,即使每对字符串都很短,整体工作量仍可能很大。长度上限约束单次比较,候选数上限约束一次请求;两者与返回最多几个结果没有直接等价关系。先查前缀索引或其他业务索引缩小候选,再运行编辑距离,通常更容易解释成本。

阈值之外的 -1 应先过滤,而不是直接作为排序键。否则按照整数升序排列时,最不相近的候选反而可能排在距离零之前。若两个候选距离相同,还需要稳定的第二排序条件,例如完整商品编号,避免结果依赖容器遍历顺序。测试当前只验证距离值,候选筛选与排序属于应用层扩展。

转义的重复执行也不是通用容错办法。原始与号经过 HTML 编码后变成实体表示,再次编码可能把实体开头的与号继续转换,最终页面显示的是实体文本。应用应给原始数据和已生成输出明确的流转位置,避免某个服务提前转义、模板层又转义一次。保存原始值、在最终输出边界按上下文编码,比在数据库中混存多种展示形式更容易维护。

JDK 没有与本篇三组功能完全对应的统一文本工具类。简单、固定的模板可以显式拼接已校验字段,从而不引入模板语言;结构化 JSON、HTML、XML 应优先使用对应格式的构造与输出设施。少量固定 ASCII 编号的比较也可以采用更窄的业务规则,不必为了“可能相似”引入通用距离排名。

双 JDK 实验验证三组场景:目的上下文的具体字符输出,白名单查询与递归开关,距离阈值和长度拒绝。没有浏览器执行、模板性能基准或全部 Unicode 等价性测试。因此,字符级断言支持 API 契约分析,不构成完整页面的安全测试报告。

手算题:label 为 ${sku}、sku 为 SKU-1,关闭值递归后替换 ${label},结果是什么?答案仍是 ${sku}。改动练习是在 Map 中增加一个很长但不递归的值,让模板重复引用它,先计算输出上界,再决定应在输入处限制什么;仅在 replace 返回后比较长度不应被记为分配前保护。

判断关键词 可迁移模式 具体选择
HTML、JSON、XML 按解析层定义编码 不把一种 escape 用遍所有位置
lookup 能力先收窄 显式 Map 键,未知变量失败
递归 数据是否继续解释需明确 商品值关闭递归展开
threshold 算法阈值与资源预算分开 先检查长度,再计算距离

可独立复跑的 Maven 项目见 下载 25–30 实验包,包含固定依赖配置、完整源码与 Maven Wrapper。

系列起点:可复现基线。