
页面上显示一排%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-1Latin-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后会把整个keyvalue的 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 typetext namenickname 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 version1.0 encodingUTF-8 ? taglib xmlnshttp://java.sun.com/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-jsptaglibrary_2_1.xsd version2.1 tlib-version1.0/tlib-version short-nameu/short-name urihttp://example.com/utils/uri function descriptionURL 解码/description nameurlDecode/name function-classcom.example.common.CookieUtil/function-class function-signaturejava.lang.String urlDecode(java.lang.String)/function-signature /function /taglib第三步JSP 里引入并使用% taglib prefixu urihttp://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 languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-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 表达式输出每一步想清楚“当前这串字符的编码形态是什么”问题基本就定位到具体哪一行了。