页面上显示一排%E5%BC%A0%E4%B8%89,第一反应是 JSP 的pageEncoding没配对,contentType改了好几遍,问题纹丝不动。实际上,EL 表达式获取 Cookie 中的中文值乱码,绝大多数时候不是页面编码的问题,而是这串值从写进 Cookie 那一刻起就带了“编码痕迹”,EL 只是原封不动地帮你取了出来。
我最早遇到这个问题是在做一个“记住昵称”的功能:用户登录后把昵称写进 Cookie,下次打开登录页,用${cookie.nickname.value}把昵称回填到输入框。结果存进去的是“张三”,读出来变成了%E5%BC%A0%E4%B8%89。后来排查多了才明白,Cookie 这个存储介质在规范层面就不允许直接放中文,之前能“存进去”只是因为容器在底层帮你做了 URL 编码,而 EL 取值时又不会自动解码。这篇文章把整条链路拆开讲清楚,顺便给你几套可以直接抄的解法。
1. 先还原现场:两种“乱码”长得不一样,排查方向也不同
1.1 百分号形态:%E5%BC%A0%E4%B8%89不是乱码,是编码后的“真身”
这是最常见的形态。浏览器开发者工具里的 Cookie 面板显示的值是%E5%BC%A0%E4%B8%89,页面上 EL 表达式输出的也是这一串。
%E5%BC%A0%E4%B8%89其实是“张三”两个汉字经过 UTF-8 编码后,再按百分号编码(URL Encoding)得到的 ASCII 字符串。E5 BC A0是“张”的 UTF-8 十六进制字节,E4 B8 89是“三”的字节。URL 编码的作用是把非 ASCII 字符转换成%XX这种安全字符,让它们能在 HTTP 头里传输。
看到这种形态,说明整个链路其实没有“坏”,只是缺了最后一步解码。你的代码很可能在写入时显式或隐式地对中文做了 URL 编码,而读取端没有做对应的 URL 解码。排查方向非常明确:找到读取端,补上URLDecoder.decode()。
1.2 问号、方框和“å¼ ä¸‰”形态:底层字符集出了问题
第二种形态是???或者页面上的方框豆腐块,这种是中文在写入 Cookie 之前就已经被丢掉了。某些容器在setValue()传入非 ASCII 字符时,会直接把非法字符替换成问号,或者静默丢弃,等你取出来时自然只剩一片问号。这种情况的根治办法是:写入时就老老实实做 URL 编码,不要试图直接塞中文。
第三种形态比较诡异:页面上显示的是å¼ ä¸‰这种北欧字符风格的东西。这是典型的“编码错位”——写入时用了 UTF-8,读取时却按 ISO-8859-1(Latin-1)解码了一部分字节。在旧版 Servlet 容器里,解析 Cookie 头默认用 ISO-8859-1,所以会出现这种张冠李戴的形态。遇到这种乱码,光靠URLDecoder.decode()救不回来,得先把字符串从 ISO-8859-1 转回 UTF-8,具体步骤在第五部分会讲。
判断方法:打开浏览器开发者工具,切到 Network 面板,看请求头的
Cookie字段里存储的原始值。如果原始值已经是%E5%BC%A0%E4%B8%89,那就是缺解码;如果原始值就是???,那是写入时就丢了;如果是å¼ ä¸‰,那是字符集错位。
2. 为什么 Cookie “装不下”中文:规范、容器、浏览器各有各的约束
2.1 Cookie 值域限制:ASCII 之外,一律不许
Cookie 的取值规则不是哪个框架定的,而是 HTTP 规范本身定死的。RFC 6265 里定义了cookie-octet只允许 ASCII 字符,其中可用的范围是:
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E翻译成人话就是:除了空格、双引号、逗号、分号、反斜杠这些特殊符号,其他 ASCII 可见字符基本都能用。但汉字不在这个集合里,它是非 ASCII 字符。你要把一个汉字塞进 Cookie,就必须找到一个 ASCII 的替身,URL 编码就是干这个的。
这就像快递包装盒上有规格限制,你要寄一个不规则的物品,得先把它塞进一个符合规格的标准箱子里。URL 编码就是这个“标准箱子”。
2.2 容器对非法字符的处理方式各不相同,别指望它帮你兜底
不同的 Servlet 容器对“往 Cookie 里塞非 ASCII 字符”的处理策略差别很大。有的容器在调用cookie.setValue("张三")时直接抛IllegalArgumentException,有的是静默替换成问号,还有的比较“宽容”,会在底层自动帮你做一次 URL 编码。
问题在于,你无法保证代码在生产环境的容器和本地开发容器的行为完全一致。我在一个老项目里就吃过亏:本地容器自动编码,页面能正常显示;上了生产环境,容器换了实现,页面直接全问号。所以,永远不要依赖容器的隐式行为,显式编码才是跨容器安全的正解。
2.3 浏览器发送 Cookie 时的行为:它把“编码后的值”原样发回
浏览器收到Set-Cookie后,会把整个key=value的 value 部分原样存储。如果你设置的是URLEncoder.encode("张三", "UTF-8")的结果,浏览器存的就是%E5%BC%A0%E4%B8%89。下次请求时,浏览器把这一串原封不动放进Cookie请求头,发送给服务器。
所以从存储到传输,整个 HTTP 链路上跑的都是 ASCII 安全的百分号编码字符串。你服务器端用request.getCookies()拿到Cookie对象后,getValue()拿到的自然也是%E5%BC%A0%E4%B8%89。这一条链路捋清楚了,你会发现乱码不是“中文乱了”,而是“编码后的中文没有被还原”。
3. 真正的坑点:${cookie.name.value}拿到的是编码后的原文,EL 不会替你解码
3.1 EL 取值到底经历了什么
很多刚开始接触 EL 的开发者会误以为${cookie.nickname.value}是一个“智能表达式”,能自动处理各种编码问题。实际上 EL 的职责非常纯粹:调用 getter,把值拿出来,仅此而已。
EL 解析${cookie.nickname}时,本质是到pageContext里找名为cookie的隐式对象,然后从 Cookie 数组中匹配name等于nickname的那个Cookie实例。接着.value就是调用这个实例的getValue()方法。
而getValue()返回什么?返回的是容器从 HTTP 请求头里解析出来的原始字符串。这个过程没有任何 URL 解码逻辑——如果有,反而奇怪了,因为容器根本不知道你当初用的是 URL 编码还是 Base64,也不知道你用的是 UTF-8 还是 GBK。
所以结论就一句话:EL 表达式把百分号编码的字符串完整取出来,不是 EL 有 bug,而是它本来就不该负责解码。
3.2 为什么想在 JSP 里直接URLDecoder.decode()却做不到
知道原因后,大多数人第一反应是:那我在 JSP 里直接解码不就行了?
问题出在 JSP 的表达式语言能力有限。EL 默认只支持几个隐式对象和运算符,你没法直接调用URLDecoder.decode()这种静态方法。如果你熟悉 JSTL,可能会想到fn函数库,比如${fn:substring()}、${fn:length()}。但 JSTL 的fn函数库只提供了字符串处理、集合操作这些基础能力,没有urlDecode这个函数。
有人会用 JSP 脚本片段硬解:
<%= URLDecoder.decode(cookie.getValue(), "UTF-8") %>确实能跑,但页面里塞 Java 代码会增加维护成本。除非项目已经老到没人管,否则不建议把这种写法留给后人。
4. 三种可靠的解法,按优先级排列
4.1 方案一:在 Servlet/Controller 端统一解码,把结果放进作用域
这是我推荐的首选方案。核心思路很简单:Cookie 读写全部集中在 Java 代码里完成,JSP 只负责展示解码后的结果。
写入端:
// 设置 Cookie,记得做 URL 编码 String nickname = "张三"; Cookie cookie = new Cookie("nickname", URLEncoder.encode(nickname, StandardCharsets.UTF_8.name())); cookie.setMaxAge(7 * 24 * 60 * 60); // 7天有效期 cookie.setPath("/"); response.addCookie(cookie);读取端:
// 读取 Cookie 并解码 Cookie[] cookies = request.getCookies(); String nickname = ""; if (cookies != null) { for (Cookie c : cookies) { if ("nickname".equals(c.getName())) { // 这里拿到的是 %E5%BC%A0%E4%B8%89,需要解码 nickname = URLDecoder.decode(c.getValue(), StandardCharsets.UTF_8.name()); break; } } } request.setAttribute("nickname", nickname);JSP 里就非常干净了:
<input type="text" name="nickname" value="${nickname}" />这样做的优点有两个。第一,JSP 里没有任何解码逻辑,后续维护的人不容易搞错;第二,所有编解码动作都收敛在 Java 层,可以用工具类统一封装,避免每个 Servlet 各写一套,写出风格迥异的代码。
4.2 方案二:自定义 EL 函数,JSP 里优雅解码
如果你实在需要在 JSP 里直接处理 Cookie 的原始值,另一个思路是自定义一个 EL 函数。这个方案适合那种大量页面都要直接读 Cookie 的场景,一次封装,到处使用。
第一步,写一个工具类:
package com.example.common; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; public final class CookieUtil { private CookieUtil() { } public static String urlDecode(String value) { if (value == null || value.isEmpty()) { return ""; } try { return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); } catch (Exception e) { return value; } } }第二步,写一个 TLD 描述文件,放到WEB-INF/tlds目录下:
<?xml version="1.0" encoding="UTF-8" ?> <taglib xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-jsptaglibrary_2_1.xsd" version="2.1"> <tlib-version>1.0</tlib-version> <short-name>u</short-name> <uri>http://example.com/utils</uri> <function> <description>URL 解码</description> <name>urlDecode</name> <function-class>com.example.common.CookieUtil</function-class> <function-signature>java.lang.String urlDecode(java.lang.String)</function-signature> </function> </taglib>第三步,JSP 里引入并使用:
<%@ taglib prefix="u" uri="http://example.com/utils" %> ${u:urlDecode(cookie.nickname.value)}自定义 EL 函数的好处是解耦干净,JSP 页面无需任何脚本片段就能完成解码。缺点是团队里如果有人不熟悉自定义函数,看到u:urlDecode会愣一下,需要你在团队文档里说明一句。
4.3 方案三:换个思路,Cookie 只存 Token,中文数据放服务端
还有一个被低估的方案:Cookie 里干脆不存中文,只存一个无法伪造的随机 token,真正要展示的昵称放在服务端内存或者数据库里。这种方案不是为了“绕开编码问题”,而是从根上消除了很多 HTTP 层面的隐患。
// 用户登录成功后 String token = UUID.randomUUID().toString().replace("-", ""); Cookie cookie = new Cookie("nicknameToken", token); cookie.setMaxAge(7 * 24 * 60 * 60); cookie.setPath("/"); response.addCookie(cookie); // 服务端保存 token -> nickname 的映射 nicknameStore.put(token, nickname);下次请求时,JSP 通过${cookie.nicknameToken.value}拿到 token,再到服务端查出昵称。Cookie 里只会有 UUID 这一串 ASCII 字符,永远不会乱码,还顺手解决了另一个隐患——不要在 Cookie 里存可读的用户名,防止信息直接暴露在浏览器端。
三种方案适用场景不同,我简单列个对比:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Servlet 端统一解码 | 常规 MVC 项目,JSP 只是展示层 | 逻辑集中,JSP 干净 | 每个 Servlet 都要写读取逻辑(可抽工具类) |
| 自定义 EL 函数 | 多页面直接读 Cookie 的场景 | 页面侧解耦,可复用 | 需要写 TLD,略有学习成本 |
| Token 替换中文 | 对敏感信息和跨域有要求的项目 | 从根上消除乱码,安全 | 需要额外的服务端存储 |
5. 踩坑清单:这四个问题最容易和“乱码”混在一起
5.1 存储用 UTF-8,读取却按 ISO-8859-1:老项目最容易踩的错位
前面说到的å¼ ä¸‰形态,根因就是编码错位。在旧版本的 Servlet 容器中,解析 HTTP 请求头里的 Cookie 时默认使用 ISO-8859-1 字符集。也就是说,你写入时用 UTF-8 编码成%E5%BC%A0%E4%B8%89没问题,但容器在解析请求头时,把这串字符按照 ISO-8859-1 解码成了一个“看似正常”的字符串。
这种错位在高版本容器中比较少见了,但老项目迁移容器、或者新旧容器混跑时会突然冒出来。遇到å¼ ä¸‰这种形态,不要急着URLDecoder.decode(),先把字符集转回来:
// 第一步:把 ISO-8859-1 解码出的字符串恢复成字节 byte[] rawBytes = value.getBytes(StandardCharsets.ISO_8859_1); // 第二步:再用 UTF-8 重新解码成正确的百分号编码串 String utf8Value = new String(rawBytes, StandardCharsets.UTF_8); // 第三步:最后做 URL 解码 String nickname = URLDecoder.decode(utf8Value, StandardCharsets.UTF_8.name());直接对å¼ ä¸‰调用URLDecoder.decode()是没用的,它根本不含%符号,会原样返回。记住这个顺序:先修字符集,再解百分号编码。
5.2 双重编码与遗漏解码:写入端改了一半,读取端忘了
这种坑来自代码演进。早期某个人在写入时做了URLEncoder.encode(),后来另一个人接手,在读取端也做了URLEncoder.encode()——双重编码,解码一次后仍是乱码;或者反过来,读取端写了解码,但写入端有人把编码去掉了,结果解码函数拿到纯中文,反而无法正常处理。
排查思路其实很简单:打开浏览器开发者工具,看当前请求的Cookie请求头原始值。如果原始值长这样:
nickname=%E5%BC%A0%E4%B8%89说明写入端做过编码,读取端只差一次解码。如果原始值是:
nickname=%25E5%25BC%25A0%25E4%25B8%2589注意%25出现了,这是百分号本身被再次编码的痕迹,说明双重编码了。如果原始值直接是:
nickname=张三这说明写入端压根没编码,读取端无论解码多少次都没意义。先看原始值,再定是加解码还是去编码,这比瞎猜快得多。
5.3 容器版本差异:不要假设“本地没问题,线上就没问题”
我观察到一个规律:很多中文 Cookie 乱码事故,本地环境怎么测都正常,一上预发就出问题。原因往往是本地容器的 Cookie 解析器自动做了 URL 解码,或者对非 ASCII 字符比较宽容,而线上容器的实现更严格,对非法字符直接替换成问号或者拒绝解析。
这是容器厂商实现差异导致的,不一定是你代码写错了。解决办法是:写入时显式URLEncoder.encode(),读取时显式URLDecoder.decode(),两端都明确指定 UTF-8,把命运掌握在自己手里。同时,尽量统一本地、测试、生产环境的容器版本,减少这种“环境差异带来的幽灵 bug”。
5.4 页面自身编码问题:别把账全算在 Cookie 头上
最后说一个容易误诊的场景:有时候${cookie.nickname.value}取出来的值其实已经被解码正确了,但页面上还是显示乱码。这种问题就出在 JSP 响应编码上。
JSP 文件头需要这样的配置:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>pageEncoding告诉容器 JSP 文件本身用什么编码解析,contentType的charset告诉浏览器响应用什么编码显示。两个都设置成 UTF-8,才能保证从文件解析到浏览器渲染一路顺畅。如果漏了pageEncoding,容器可能用默认编码解析 JSP 文件,中文被错误解析后,即使 EL 输出值正确,页面照样显示乱码。
判断方法:看页面 HTML 源码,如果源码里直接是%E5%BC%A0%E4%B8%89,那问题在 Cookie 解码链路;如果源码里就是乱码,但 Cookie 原始值正常,那问题在页面编码设置。
把这一整套链路捋顺之后,Cookie 中文乱码基本不会再对你造成困扰了。我现在的习惯是:统一封装一个CookieUtil,把set、get、encode、decode全部收敛在一个工具类里,项目里禁止任何人直接new Cookie("nickname", "张三")这种裸写。遇到乱码问题,先画一遍字符串的编码流转路径——从内存里到 Cookie,再到浏览器存储,再到请求头发送,最后回到 EL 表达式输出,每一步想清楚“当前这串字符的编码形态是什么”,问题基本就定位到具体哪一行了。