有次在给客户做开放平台对接,回调报文里某个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=/home&from=wechat"; p.title = "Tom & Jerry <script>alert(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=/home&from=wechat","title":"Tom & Jerry <script>alert(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 表里的码点是 U+003D,所以 Gson 用\u003d表示;与号是 U+0026,所以输出\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":"</script><script>alert(1)</script>"}如果这段 JSON 被直接嵌入到页面的<script>区块里,浏览器解析 HTML 时会把</script>当成脚本结束标签,后面的<script>alert(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 type="text" value='{"url":"https://example.com/?a=1&b=2"}' />在 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 系统用的字符串里
=已经被转义成\u003d,B 系统验签时用的是原始等号,两边算出来的摘要永远不一致。 - 日志检索:在日志平台里搜
&,怎么都搜不到,因为落库的 JSON 里存的是\u0026。 - 字符串匹配:用
indexOf("&")去 JSON 原文里找与号,返回 -1,排查半天发现它变成了\u0026。 - 跨语言 / 跨库协作:A 端用 Gson 生成 JSON,B 端用别的库解析并重新序列化,两边对同一个对象生成的字符串长得不一样,导致数据对账失败。
这里还要特别强调一点:\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=/home&from=wechat","title":"Tom & Jerry <script>alert(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 TypeAdapter<String> { @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=/home&from=wechat\uXXXX是 JSON 字符串中合法的转义序列,JsonReader在读字符串时会把它翻译成对应字符。所以同一个 Gson 实例内部,"转出去"和"读回来"是完全对称的。
真正的不对称来源于"不同的序列化配置"。A 系统用默认 Gson 转出去得到\u0026,B 系统用关闭 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=/home&from=wechat而 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 端用 Gson,B 端用 Jackson,A 端序列化出的 JSON 里=是\u003d,B 端收到后用自己的 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, Class<T> 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 上可能略有差异。