
有次在给客户做开放平台对接回调报文里某个url字段的值长这样{url:https://example.com/login?redirect\u003d/home\u0026from\u003dwechat}我第一反应是对方把 URL 编码和 JSON 转义搞混了怎么好端端的等号变成了\u003d、与号变成了\u0026。后来跟着报错信息一路查到 Gson 源码才发现这根本不算 bug而是 Gson 从设计上就默认开启的一个隐藏策略——HTML 安全转义。标题里写的转译其实就是平时说的转义escape。今天就把这个事彻底说清楚Gson 到底会给哪些字符改头换面改动背后的动机是什么哪些场景会因此踩坑以及应该怎么按需关掉它。1. 先搞清楚Gson 到底会把哪些字符改头换面1.1 一段最简单的代码看 Gson 输出的真实效果先做一个最直接的实验。定义一个非常普通的 POJO里面有两个字符串字段一个放 URL一个放普通标题import com.google.gson.Gson; import com.google.gson.GsonBuilder; public class GsonEscapeDemo { static class Param { public String url; public String title; } public static void main(String[] args) { Param p new Param(); p.url https://example.com/login?redirect/homefromwechat; p.title Tom Jerry scriptalert(1)/script; Gson defaultGson new Gson(); System.out.println(默认: defaultGson.toJson(p)); Gson safeGson new GsonBuilder().disableHtmlEscaping().create(); System.out.println(关闭: safeGson.toJson(p)); } }运行结果如下默认: {url:https://example.com/login?redirect\u003d/home\u0026from\u003dwechat,title:Tom \u0026 Jerry \u003cscript\u003ealert(1)\u003c/script\u003e} 关闭: {url:https://example.com/login?redirect/homefromwechat,title:Tom Jerry scriptalert(1)/script}同样一个对象默认 Gson 和关闭 HTML 转义的 Gson输出差别很大。默认情况下等号被替换成了\u003d与号被替换成了\u0026小于号被替换成了\u003c大于号被替换成了\u003e。而?、/这些字符则原样保留没有被处理。这就是很多人第一次遇到问题时最困惑的地方为什么有的字符被转义有的字符不转义答案要看 Gson 内置的字符替换表。1.2 完整字符清单除了 、还有这些Gson 的字符串转义其实分两类一类是 JSON 规范本身要求的强制转义另一类是 Gson 在默认配置下额外追加的 HTML 安全转义。这两类很容易混在一起下面分开列。先看 Gson 默认会比 JSON 规范多做的那部分——HTML 敏感字符原字符含义Gson 默认输出说明等号\u003d等号在 HTML 标签属性中用于绑定属性值与号\u0026与号是 HTML 实体的起始字符小于号\u003c小于号是 HTML 标签的起始字符大于号\u003e大于号是 HTML 标签的结束字符单引号\u0027单引号在 HTML 属性中可用于包裹属性值双引号\双引号在 JSON 中必须转义Gson 按标准处理再看 JSON 规范本身强制要求转义的字符这部分任何 JSON 库都会处理不是 Gson 特有的原字符输出说明\防止与字符串定界符冲突\\\反斜线本身就是转义符号退格 0x08\b控制字符换页 0x0C\f控制字符换行 0x0A\n控制字符回车 0x0D\r控制字符制表 0x09\t控制字符0x00 ~ 0x1F 其他\uXXXX统一用 Unicode 码点表示所以回答标题里的问题Gson 遇到会转义的字符除了 、还有 、、、 这五个 HTML 敏感字符以及 JSON 规范规定的控制字符和反斜线。中文、空格、%、?、#、这些都不在默认转义范围内。1.3 转义形式的本质\u003d 是 Unicode 码点不是 URL 编码\u003d这种形式很多人一看就慌以为是编码乱掉了其实它只是 Unicode 码点的十六进制表示。等号在 Unicode 表里的码点是 U003D所以 Gson 用\u003d表示与号是 U0026所以输出\u0026。这是 JSON 标准里合法的字符串转义方式任何支持 JSON 的解析器都能识别。这里有个非常容易踩的 Java 源文件坑。在 Java 代码里写\u003d编译期就会直接变成字符串因为 Java 编译器会先处理 Unicode 转义。但如果你在 JSON 字符串里看到的是\u003d想在 Java 源码里构造这个 JSON 字面量必须写成\\u003d也就是把反斜线本身转义一下。// 注意这行输出的是等号 System.out.println(\u003d); // // 这行输出的是 6 个字符\u003d System.out.println(\\u003d); // \u003d很多同学写测试用例时发现怎么都对不上就是栽在这上面。记住一个原则你在日志、抓包里看到的\u003d在 Java 字符串里要表达它就用双反斜线。2. 为什么 Gson 要默认转义 和 ——HtmlSafe 的设计动机2.1 从 Web 安全的起源说起Gson 是 Google 出的库早期大量使用场景是把 Java 对象序列化成 JSON然后直接嵌到 HTML 页面的script标签里或者塞进input、div的某个属性中。在这种场景下、这类字符非常危险。举个典型例子。假如业务字符串里包含/script{title:/scriptscriptalert(1)/script}如果这段 JSON 被直接嵌入到页面的script区块里浏览器解析 HTML 时会把/script当成脚本结束标签后面的scriptalert(1)/script就会变成真正的可执行脚本形成 XSS 漏洞。Gson 把转义成\u003c、转义成\u003e之后这段内容就变成了{title:\u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e}浏览器拿到这段字符时里面没有真实的和自然不会形成 HTML 标签结构。所有字符都只是普通文本XSS 注入就失效了。2.2 等号和与号为什么也要转义和转义的理由很充分那、呢它们不参与标签结构为什么也要动因为 Gson 序列化出来的 JSON 不只会被放进script里还可能被放进 HTML 标签属性里比如input typetext value{url:https://example.com/?a1b2} /在 HTML 属性值里是 HTML 实体的起始字符。如果 JSON 中的没有被处理浏览器解析这个属性时可能把b当作一个实体引用的开头导致属性值被错误截断或解析页面展示就乱了。在属性中用于连接属性名和属性值在某些宽松解析环境下也可能引起属性边界混乱。Gson 索性把、、、、、这一整组 HTML 特殊字符全部用\uXXXX或\形式输出。这样无论这段 JSON 被塞到 script 标签里、HTML 属性里还是别的什么 HTML 结构中本质上都只是一串不会触发任何 HTML 结构语义的普通字符。这个策略在 Gson 里就叫HtmlSafe从设计目的看它本质上是面向 Web 页面输出场景的防注入策略。2.3 哪些场景会因此踩雷HtmlSafe 策略对JSON 要嵌进 HTML 页面的场景是福对JSON 只做纯数据交换的场景却常常是祸。最典型的踩雷场景包括URL 参数拼接拿到 Gson 输出的 JSON 字符串直接拼到 GET 请求的 query 里结果服务端收到的参数里全是\u003d、\u0026解析参数时完全对不上。签名 / 哈希计算对 JSON 字符串做 MD5、HMAC 或 SHA 摘要时A 系统用的字符串里已经被转义成\u003dB 系统验签时用的是原始等号两边算出来的摘要永远不一致。日志检索在日志平台里搜怎么都搜不到因为落库的 JSON 里存的是\u0026。字符串匹配用indexOf()去 JSON 原文里找与号返回 -1排查半天发现它变成了\u0026。跨语言 / 跨库协作A 端用 Gson 生成 JSONB 端用别的库解析并重新序列化两边对同一个对象生成的字符串长得不一样导致数据对账失败。这里还要特别强调一点\u003d对于 JSON 标准解析器来说完全合法fromJson会正确还原成。所以真正的坑从来不是Gson 序列化出来的东西别人解析不了而是你的下游没有经过 JSON 解析直接把 JSON 字符串当普通文本去用。3. 实用解法什么时候该关什么时候不该关3.1 最简单直接的方式disableHtmlEscaping()Gson 的GsonBuilder提供了disableHtmlEscaping()方法调用之后这个 Gson 实例生成 JSON 时就不会再把 HTML 字符转成\uXXXX形式Gson gson new GsonBuilder() .disableHtmlEscaping() .create();改完之后前面例子里的输出变成{url:https://example.com/login?redirect/homefromwechat,title:Tom Jerry scriptalert(1)/script}、、、、都保持原样只有 JSON 规范强制要求的、\、换行、控制字符等仍然会被转义。也就是说关闭HtmlSafe并不会让 Gson 输出非法 JSON它依然是一个完全符合 JSON 标准的字符串。这里要提醒一句disableHtmlEscaping()是 Gson 实例级别的全局配置一旦设置这个实例所有toJson输出都不再执行 HTML 转义。如果你的项目同时存在给前端页面嵌入和纯接口数据交换两种用法就要评估一下是否所有调用方都接受关闭后的行为。3.2 如果只是个别字段需要保留特殊字符字段级自定义处理有时候只想让某个字段输出原始的和其他字段保持默认行为那么全局关闭可能影响面太大。这种情况下可以考虑字段级的JsonSerializer/JsonDeserializer。Gson 从 2.8 开始JsonWriter提供了setHtmlSafe(boolean)方法可以更细粒度地控制某个值输出时是否执行 HTML 转义import com.google.gson.*; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import java.io.IOException; public class RawUrlAdapter extends TypeAdapterString { Override public void write(JsonWriter out, String value) throws IOException { if (value null) { out.nullValue(); return; } // 这个字段单独关掉 HTML 转义 out.setHtmlSafe(false); out.value(value); } Override public String read(JsonReader in) throws IOException { return in.nextString(); } }注册时用JsonAdapter注解挂在字段上即可public class Param { JsonAdapter(RawUrlAdapter.class) public String url; public String title; }这样url字段序列化时不会被转义成\u003d而title字段仍然使用 Gson 默认的 HtmlSafe 行为。不过说实话这种字段级方案在简单场景下有点杀鸡用牛刀而且setHtmlSafe(false)会临时改变 writer 的状态如果同一个 writer 后续还要写别的值要注意状态是否会恢复。Gson 内部会在value()写入后再恢复但你自己实现的 Adapter 最好也做一次状态保存和恢复避免污染后续输出。如果只是输出给程序的 JSON 不想看到\u003d我个人建议优先用全局disableHtmlEscaping()简单、直观、可预期。字段级适配器适合那种大部分内容要面向 HTML 输出只有个别字段要保留原始字符的混合场景。3.3 序列化与反序列化的对称性转得回来吗很多人担心一个问题Gson 把转成\u0026那用fromJson读回来的时候能还原成吗答案是肯定的。String json {\url\:\https://example.com/login?redirect\\u003d/home\\u0026from\\u003dwechat\}; Param parsed new Gson().fromJson(json, Param.class); System.out.println(parsed.url); // 输出: https://example.com/login?redirect/homefromwechat\uXXXX是 JSON 字符串中合法的转义序列JsonReader在读字符串时会把它翻译成对应字符。所以同一个 Gson 实例内部转出去和读回来是完全对称的。真正的不对称来源于不同的序列化配置。A 系统用默认 Gson 转出去得到\u0026B 系统用关闭 HtmlSafe 的 Gson 从同一个对象生成得到两边对同一份数据的 JSON 表达不一致就会在字符串比对、签名校验、消息去重等环节暴露出问题。这也是为什么我建议团队项目里把 JSON 工具统一成一个公共类并且明确到底开不开 HtmlSafe而不是各写各的new Gson()。4. 实战排查等号与与号引发的签名校验失败全过程4.1 场景还原URL 参数进对象再出对象sign 变了之前接过一个真实案例A 系统把业务数据封装成 JSON作为 URL 参数传给 B 系统的开放接口。为了防篡改双方约定对 JSON 字符串加盐后做 MD5把摘要放在sign字段里。A 系统本地自测一切正常但 B 系统一直报验签失败。我拿到两边的报文一对比发现 A 系统签名时用的字符串里URL 字段长这样https://example.com/login?redirect/homefromwechat而 B 系统收到的参数URL 字段长这样https://example.com/login?redirect\u003d/home\u0026from\u003dwechatA 系统传来的sign是按原始等号、与号那版计算的B 系统验签时用的是 URL 解码后的 JSON 字符串里面已经是\u003d、\u0026了两边摘要自然对不上。4.2 排查链路从抓包到源码整个过程排查了将近两个小时现在复盘一下完整链路很有代表性。第一步抓包看原始报文。发现 B 系统收到的 JSON 字符串里出现大量\u003d和\u0026。我一开始以为是 URL 编码问题因为和在 query string 里本身就是保留字符但仔细看发现转义形式不是%3D、%26而是反斜线加小写 u 的 Unicode 形式。这说明问题不在 URL 编码层。第二步用indexOf()在报文字符串里找与号返回 -1。当时的报文内容是...redirect\u003d/home\u0026from\u003dwechat...我直接用字符串搜索当然找不到。这个环节很容易误导人让人以为是内容被截断或者编码错了。第三步把 JSON 字符串原样打到日志里确认这就是 Gson 序列化的产物。排查到这一步才意识到真正要回答的问题不是URL 为什么编成这样而是Gson 为什么输出\u003d。第四步去 Gson 源码里搜替换表。在com.google.gson.stream.JsonWriter中可以看到内置的字符替换数组里面有、、、、等字符的\uXXXX映射。看到这些映射的注释确认是 HtmlSafe 策略在起作用。第五步用GsonBuilder().disableHtmlEscaping().create()替换原先的new Gson()重新生成报文\u003d和\u0026消失验签通过。这个案例给人最大的启发是排查转义类问题不要光盯着字符串内容本身要先判断这个字符串是从哪个库、哪个配置出来的。看到\uXXXX第一反应应该是哪个工具做了 Unicode 转义而不是急着做 URL 解码。4.3 不同解法在真实项目里的取舍针对这种场景可选的方案其实有几种各有适用条件。方案一全局关闭 HtmlSafe。最省事适合绝大多数纯后端 JSON 数据交换场景。只要你的 JSON 不会直接嵌入 HTML 页面关闭它基本没有副作用。方案二字段级 TypeAdapter。适合个别字段有特殊需求、整体还要保留 HtmlSafe 的场景但代码复杂度高一些而且要处理好 writer 状态。方案三序列化后再做字符串替换把\u003d手工替换回。这种方法非常不推荐因为你要把所有可能出现的转义序列都处理一遍漏一个就埋雷而且在某些业务字段本身就需要\u003d字面量时会误伤。方案四不让 Gson 处理改用 Jackson 或 Fastjson 做序列化。这是换库思路但为了一个转义配置换掉整个 JSON 框架代价偏大除非项目本来就在做迁移。我的建议很简单先确认 JSON 的消费方。如果消费方全部是程序没有人把 JSON 字符串直接嵌到 HTML 里那就全局关闭。如果 JSON 要同时服务于页面输出和接口调用建议拆成两个 Gson 实例一个开 HtmlSafe一个关掉按用途选择。5. 同类库横向对比Jackson、Fastjson 的转义策略5.1 默认转义行为对比表Gson 的 HtmlSafe 行为在 Java 生态里并不是通用的很多用了多年 Jackson、Fastjson 的团队第一次见\u003d都会愣一下。下面这个对比表能帮你快速定位不同库的默认表现字符Gson默认Jackson默认Fastjson默认\u003d原样输出原样输出\u0026原样输出原样输出\u003c原样输出原样输出\u003e原样输出原样输出\u0027原样输出原样输出\\\中文原样输出原样输出原样输出注意Jackson 和 Fastjson 并不是完全不处理特殊字符它们会遵守 JSON 规范把双引号、反斜线、控制字符转义掉但不会像 Gson 那样额外处理 HTML 敏感字符。所以同样一个对象Gson 和 Jackson 输出的 JSON 字符串可能存在差异但对 JSON 解析器来说都是等价的。5.2 跨库协作时最容易遇到的问题跨库协作的麻烦点在于字符串不一致而不是解析不兼容。A 端用 GsonB 端用 JacksonA 端序列化出的 JSON 里是\u003dB 端收到后用自己的 Jackson 重新序列化得到的是原始。于是同一份数据在不同系统里留下了不同版本的字符串。这种情况在下面几类业务中特别容易爆雷消息队列里的消息 body 被多个消费者消费消费者对消息做内容去重或做摘要。接口签名/验签机制直接作用在 JSON 字符串上。系统间对账用 JSON 字符串的哈希值作为记录指纹。数据库里同一个 JSON 字段有的系统写入的是转义版有的系统写入的是原样版查询和比对时产生脏数据。解决思路有两个方向一是统一所有系统的 JSON 库和配置比如都用disableHtmlEscaping()二是不对 JSON 字符串本身做签名、去重、哈希而是对解析后的业务字段做这些操作。第二种其实更稳因为 JSON 的键顺序、转义风格在不同版本之间都可能变化把字符串当作业务指纹本身就是脆弱的。5.3 一个可以复用的 JSON 工具类既然聊到了统一配置分享一个我项目里常用的工具类写法。核心就一个全局单例 Gson 实例显式关闭 HtmlSafe避免到处new Gson()导致配置不一致。import com.google.gson.Gson; import com.google.gson.GsonBuilder; public final class JsonUtils { private static final Gson GSON new GsonBuilder() .disableHtmlEscaping() .create(); private JsonUtils() { } public static String toJson(Object obj) { return GSON.toJson(obj); } public static T T fromJson(String json, ClassT clazz) { return GSON.fromJson(json, clazz); } public static T T fromJson(String json, Type type) { return GSON.fromJson(json, type); } }如果一个项目里同时存在页面嵌入和接口数据交换两种需求就建两个单例public class GsonHolder { // 页面嵌入场景保留 HtmlSafe public static final Gson HTML_SAFE_GSON new GsonBuilder().create(); // 数据交换场景关闭 HtmlSafe public static final Gson PLAIN_GSON new GsonBuilder().disableHtmlEscaping().create(); }然后在具体代码里按场景选择而不是每次用new Gson()现造实例。这样团队里任何人的输出行为都是一致的排查问题的时候能少一大半干扰。最后再分享一个我自己的小习惯。只要是新建 Java 项目JSON 工具类里我会默认写disableHtmlEscaping()除非这个项目明确有把 JSON 嵌入 HTML 页面的需求。绝大多数后端服务、微服务网关、消息队列的 JSON 消费端都是程序不是浏览器HtmlSafe 带来的转义只会增加排查成本和字符串不一致风险。当然如果你还在用老版本的 Gson建议先看一眼JsonWriter源码里有没有setHtmlSafe方法老版本 API 上可能略有差异。