☰
Cookie中文乱码怎么办?从EL表达式到Tomcat编码链路全解析
2026/10/12 20:48:21 网站建设 项目流程

1. 乱码背后的真实面目

先说说这个问题的典型画面:项目里明明在Java代码中把中文字符写进了Cookie,浏览器F12里也能看到一串看不清的字符,等到页面上用 ${cookie.username.value} 去渲染时,要么是一堆 %E4%B8%AD%E6%96%87 这样的编码串,要么直接变成问号或乱码。这个场景在JSP老项目里几乎天天都能碰到,即便换成Spring Boot内嵌Tomcat的新项目,只要还在用传统Cookie存中文,该乱还是乱。

这个问题的本质不是“浏览器太笨”,而是Cookie本身就不让你直接存中文。HTTP协议对Cookie值有严格的字符集限制,RFC 6265明确规定Cookie的取值只能是ASCII可见字符,中文这种多字节Unicode字符根本不在允许范围内。你非要把“张三”直接塞进Cookie,服务器容器要么报非法字符错误,要么默默吞掉,要么当作ISO-8859-1硬编码处理,结果就是前端拿到的东西完全不是你想的那个值。

这篇文章适合谁看?如果你是还在维护JSP/Servlet老项目的同学,或者正在用Spring Boot但依然习惯在Cookie里放用户昵称、跳转回显的中文数据,那这篇文章能让你彻底搞清楚编码链路,并给出可以直接抄作业的三种解法。另外,想搞明白EL表达式为什么不帮你自动解码、以及Tomcat不同版本为什么行为不一致的,也可以放心往下看。

2. 乱码发生的链路解析

2.1 写Cookie时其实就埋下了雷

绝大多数开发者在写Cookie时是这样的:

Cookie cookie = new Cookie("username", "张三"); response.addCookie(cookie);

这段代码在本地Tomcat 8上跑,浏览器收到的响应头大概长这样:

Set-Cookie: username=张三

表面上看挺正常对吧?但你要是在Chrome的Application面板里查看这个Cookie,会发现Value已经变成了一串URL编码形态的字符。这其实就是Tomcat在内部替你做了一步转换,它会把非ASCII字符先URL编码再发送,但不同容器、不同版本做这一步的方式和时机都不一样,这就导致后续读取时的不确定性。

更麻烦的是,直接往Cookie里塞中文,在跨浏览器时行为完全不一致。新版Chrome对非ASCII的Cookie字符会直接拒绝写入,Firefox则是宽松模式照单全收但编码混乱,Safari有时能存但返回时已经变了样。也就是说,“本地能用”不代表“哪里都能用”,这个问题一旦上线,就是隐患。

2.2 EL表达式的“裸读”缺陷

页面上的写法通常是这样的:

<p>欢迎:${cookie.username.value}</p>

EL表达式在这条链路上做的事情非常简单:从request的cookie数组中匹配name为username的Cookie对象,取出它的value字符串,然后直接输出。它不会主动去解码,也不会判断这个value是不是URL编码之后的结果。你觉得EL应该聪明一点,自动给解回来?很遗憾,EL的设计原则是“只做取值和表达式计算”,不掺和任何编码转换逻辑。

这里就出现了第一个岔路:如果写入时容器帮你做了URL编码,那么EL读到的就是 %E5%BC%A0%E4%B8%89 这种形态;如果写入时压根没编码、而容器也放任不管,那么EL读到的可能是原始UTF-8字节被按ISO-8859-1解码后的乱码字符。两种情况显示效果都是“乱”,但根源完全不同。

2.3 容器差异和请求编码的干扰

除了Cookie本身的问题,Tomcat 8.5之前和之后的版本行为差异也是重灾区。老版本Catalina对HTTP头部的解码默认使用ISO-8859-1,读取Cookie字符时也会沿用这一套,导致UTF-8写入的中文被拆成两个字节、每个字节映射成一个拉丁字符,比如“张”的UTF-8编码是E5 BC A0,按ISO-8859-1逐个字节解码就成了三个奇怪的符号。而Tomcat 8.5+虽然把URIEncoding默认改成了UTF-8,但Cookie的读取依然有自己独立的一套配置:org.apache.tomcat.util.http.ServerCookie 里的强制编码开关在不同版本里要么默认开、要么默认关,继续给人添堵。

另外,如果页面里同时还设置了 request.setCharacterEncoding("UTF-8"),那只会影响POST表单参数的解码,对Cookie一点用都没有。很多人在这里绕了很大一圈,不断给JSP加pageEncoding、给filter加编码转换,结果Cookie该乱还乱,就是因为方向完全搞错了。

3. 为什么说“编码再解码”才是正路

3.1 写Cookie前必须URL编码

先把最核心的原则说清楚:Cookie里不要存原始中文,存就存URL编码后的字符串。URL编码会把“张三”转成 %E5%BC%A0%E4%B8%89 这样的纯ASCII串,完全满足Cookie的字符限制,也不会再触发容器的偷偷转换。

import java.net.URLEncoder; String name = "张三"; Cookie cookie = new Cookie("username", URLEncoder.encode(name, "UTF-8")); cookie.setPath("/"); cookie.setMaxAge(7 * 24 * 60 * 60); response.addCookie(cookie);

这样写的好处显而易见:无论是老Tomcat还是新Tomcat,无论是Chrome还是Firefox,这种纯ASCII的Cookie值都能稳定传输。那URL编码后的字符串是 %E5%BC%A0%E4%B8%89,浏览器在随后的请求中会在Cookie请求头里原样带回来:username=%E5%BC%A0%E4%B8%89。也就是说,你写入的字符和请求回来的字符能保证完全一致,这也是后续解码能够成功的前提。

3.2 读取时不是简单反向操作

写入是编码,读取自然是解码头。但要小心一个细节:URLDecoder 解码时,默认会把 + 号当作用空格处理。如果你编码之前的中文里带有空格,URLEncoder.encode 会把空格变成 +,那么解码回来后 + 会变成空格,这是符合预期的。但如果某个Cookie的值本身里含有真正意义的内容,就一定要用 UTF-8 明确指定字符集,并且留意是不是有多次编码叠加的情况。比如有人在一个Filter里对Cookie值做了一次编码,又在Controller里做了一次编码,那对应地也得解两次,漏一次就会乱。

还有一点经常被忽略:Cookie的值有可能是某个工具类统一set进去的,作为一个改他项目的开发者,你不一定清楚它到底编码了几次。这时候我建议你直接在JSP页面上临时打印一下 ${cookie.username.value},看它是以 % 开头的一段纯ASCII串,还是肉眼可见的中文乱码。这个简单的观察决定了你后面的解码策略:看到 %E5%BC%A0 就解一层;看到像 “å¼ ä¸‰” 这种拉丁字符拉伸形态,说明容器已经帮你按ISO-8859-1解码过,还得先纠正容器的编码策略。

4. 三种落地方案对比与实战

4.1 方案一:后端拦截,解码后放入request作用域

最传统也最稳的方案,是在进入JSP之前,先把Cookie解码好放到一个新的作用域变量里:

// Servlet中 Cookie[] cookies = request.getCookies(); String username = null; if (cookies != null) { for (Cookie cookie : cookies) { if ("username".equals(cookie.getName())) { username = URLDecoder.decode(cookie.getValue(), "UTF-8"); break; } } } request.setAttribute("decodedUsername", username); // forward到JSP...

然后页面上直接用 ${decodedUsername},干净利落。

这种方案推荐指数最高,因为它不光解决了EL读取的问题,还把“解码这个动作”收敛在了Java代码里。后续如果有多个JSP页面需要这个值,只需在Filter里统一解码放入request,所有页面都能共享。而且它不受EL版本影响,哪怕你还在用古老的JSP 2.0规范,也完全能用。

具体落地步骤:

  1. 把解码逻辑封装成一个工具方法,输入Cookie数组和key,返回解码后的值。
  2. 在登录成功或身份校验的Filter里调用该工具,把结果setAttribute到request作用域。
  3. JSP页面上用 ${decodedUsername} 或 ${requestScope.decodedUsername} 输出。

这套方案的好处是直观、简单、无需改变项目原有的前端结构。缺点是如果你有一堆Cookie键都要用,每个都要往request里塞一个属性,稍微有点繁琐。但相对于代码清晰度来说,这点繁琐完全可接受。

4.2 方案二:自定义EL函数,逻辑内聚

如果你不想给每个Cookie键单独设置Attribute,也不想Filter塞一堆变量,那可以自定义一个EL函数,在JSP表达式里直接调用解码:

先建一个类:

package com.demo.common; import java.net.URLDecoder; public final class CookieUtils { public static String decode(String value) { if (value == null || value.isEmpty()) { return ""; } try { return URLDecoder.decode(value, "UTF-8"); } catch (Exception e) { return value; } } }

然后在 WEB-INF 下建一个TLD文件,比如 functions.tld:

<?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"> <display-name>Custom Functions</display-name> <tlib-version>1.0</tlib-version> <short-name>cfn</short-name> <uri>http://demo.com/functions</uri> <function> <name>decode</name> <function-class>com.demo.common.CookieUtils</function-class> <function-signature>java.lang.String decode(java.lang.String)</function-signature> </function> </taglib>

页面里这样用:

<%@ taglib prefix="cfn" uri="http://demo.com/functions" %> <p>欢迎:${cfn:decode(cookie.username.value)}</p>

这种方案最大的优点是把解码逻辑封装成了一个可复用的语言级功能,页面上看起来非常清爽,适合项目中多处地方都要读取Cookie中文值的场景。而且EL函数的执行时机是在服务端渲染阶段,不会额外增加一个Http请求,性能影响可以忽略。

不过要注意的是,EL函数签名只支持静态方法,所以 CookieUtils.decode 一定要是 public static。另外,不同的容器对TLD文件位置有要求,一般必须放在 WEB-INF 目录或者类路径的 META-INF 下,放错了就会报找不到函数。

4.3 方案三:EL 3.0直接调用静态方法

如果你用的是较新版本容器,比如Tomcat 8.0以上、Tomcat 9、Tomcat 10,以及Spring Boot内嵌容器,那可以利用EL 3.0+ 支持直接调用类的静态方法这个特性,免去写TLD的麻烦。

JSP页面顶部这样引入:

<%@ page import="java.net.URLDecoder" %>

正文里这样写:

<p>欢迎:${URLDecoder.decode(cookie.username.value, "UTF-8")}</p>

是不是非常简洁?但这里有几个必须注意的坑:

第一,Tomcat 8.0之后才完整支持EL 3.0,更低版本会直接报错。第二,JSP页面的 import 只能引入java.*下的类,URLDecoder没问题,但如果你自己定义的工具类不在java包下,这样直接用是做不到的,还是得回到方案二的TLD函数。第三,有些项目配置了严格的函数校验,会限制JSP中能调用的静态方法范围,此时这种写法会直接被安全策略拒绝。

我在实际项目中其实很少用它来解Cookie,因为Cookie属于古老但依然敏感的技术点,我更倾向于让它在后端解码,页面只做展示。不过在快速验证、临时debug、不需要长期维护的场景里,这种写法是真方便,能少建一个TLD文件。

4.4 三种方案横向对比

维度后端解码放入request自定义EL函数EL 3.0静态方法调用
实现复杂度低中低
适用范围所有容器和JSP版本所有容器和JSP版本仅Tomcat 8+等EL 3.0容器
页面代码侵入低(直接用变量)中(taglib + 函数调用)中(import + 函数调用)
复用性需搭配Filter高中
安全性高高取决于静态方法限制
适用场景项目统一入口多处页面需要该逻辑临时调试、极简页面

5. 常见坑位与排查思路实录

5.1 加了编码Filter为什么Cookie还是乱

这是最多人问的一个问题。很多项目里已经配置了CharacterEncodingFilter,把request.setCharacterEncoding("UTF-8")和response.setCharacterEncoding("UTF-8")都给做了,结果Cookie依然一团糟。原因很简单:CharacterEncodingFilter只影响请求体参数的解码和响应体的编码,而Cookie是放在HTTP请求头里传输的,走的是容器内部的Cookie解析逻辑,完全不在这个Filter的管辖范围内。

你真正要做的是让容器在解析Cookie时使用UTF-8。在Tomcat 8和9里,连接器有个配置项 URIEncoding,它不仅能控制URI中的参数解码,对Cookie解码行为也有连带影响。如果用的是Spring Boot,可以在application.properties里加:

server.servlet.encoding.force-response=true server.servlet.encoding.charset=UTF-8

但如果还不行,就要去检查Tomcat的 context.xml 或 server.xml 中是否有显式的 CookieProcessor 配置。有些老项目为了让某些远古浏览器能兼容,会自定义 LegacyCookieProcessor,这个处理器在处理非ASCII字符时的行为比较“灵活”,容易出现意想不到的转换。把它换成默认的 Rfc6265CookieProcessor 后,至少能保证编码逻辑符合现代规范。

5.2 密码里的特殊字符把页面搞崩了

有人会对Cookie值先做Base64编码再放入,以为能绕开URL编码的麻烦。这带来一个很隐蔽的问题:Base64产生的字符中可能包含 +、=、/ 这几个字符,它们在不同编码语境下的含义完全不同。比如 + 在URL解码时会被转成空格,= 可能被解析工具当作键值对分隔符,/ 则可能干扰路径匹配。如果后续读取只用了Base64解码而忘了URLDecoder解码,那特殊字符就会在两层编码之间夹生出奇奇怪怪的结果。

我的建议是:Cookie存什么,读取时就反着做什么,顺序不能乱。如果你存的时候先URLEncoder,再Base64,那读取时必须先Base64解码,再URLDecoder解码。别觉得这啰嗦,乱序解码头是这类问题里最容易犯的错误。

5.3 希腊字母、表情符号为什么偶尔正常偶尔乱

中文在多数容器里至少有个默认处理,但希腊字母、俄文、Emoji等非中文Unicode符号更容易触发问题。一个原因是这些字符的UTF-8字节数不固定,比如Emoji通常是4字节,而ISO-8859-1是按单字节解码的,4字节被拆成4个拉丁字符,你再怎么设置页面编码也无法还原。另一个原因是部分容器版本对4字节字符的Cookie支持不完整,甚至会直接截断。

因此如果你要在Cookie里放Emoji或特殊符号,最稳妥的方式只有一种:UTF-8编码之后做Base64,然后Base64字符串本身再URL编码。这个组合虽然绕,但对浏览器和容器来说都是纯ASCII字符串,绝不会出幺蛾子。

5.4 排查乱码时我自己习惯的四步法

第一步:打开浏览器开发者工具,看响应头的Set-Cookie原文到底是什么形态。如果看到的是 %E5%BC%A0%E4%B8%89,说明容器自动做了URL编码,你的解码层没跟上;如果是原始“张三”,说明容器没编码,或者编码没生效。

第二步:看请求头的Cookie原文。如果请求和响应的Cookie值形态一致,说明传输链路没问题,问题在服务端渲染阶段的解码环节;如果不一致,说明在某个中间环节被二次处理了,这时候就该翻Filter和拦截器代码。

第三步:在JSP页面直接输出 ${cookie.username.value} 和 ${cookie.username.value.getClass()} ?后者不一定能在JSP里直接输出类名,但你可以用<% %>脚本片段打印一下:

<% Cookie target = null; Cookie[] cks = request.getCookies(); if (cks != null) { for (Cookie c : cks) { if ("username".equals(c.getName())) { target = c; break; } } } if (target != null) { out.println("[" + target.getValue() + "]"); } %>

这一步能帮助区分:拿到的是“字面编码串”还是“已经乱码后的字符”。

第四步:确定容器版本,去查对应版本的CookieProcessor行为。毕竟Tomcat 7和Tomcat 8.5处理Cookie的默认逻辑差很多,网上很多方案是针对老版本的,拿到新版本里反而画蛇添足。

排查思路整理成速查表:

表现指向的问题优先处理方向
显示 %E5%BC%A0%E4%B8%89只编码未解码在读取链路补解码
显示 å¼ ä¸‰ISO-8859-1按字节解码调整容器Cookie解码编码
显示 ?? ? 问号编码过程信息丢失检查写入前数据来源,重写编码逻辑
请求头正常,响应头异常响应阶段二次编码检查过滤器与拦截器逻辑
本地正常,测试环境乱环境容器版本不一致对齐容器版本及CookieProcessor

5.5 一个让我印象深刻的实战案例

有一次我接手一个老系统,界面上一排用户名全是乱码,但Cookie在浏览器里看着是正常的。按我的四步法排查后发现,写入时加了一次URLEncoder.encode,但是加在一个全局的响应Filter里,这个Filter会把所有Cookie值都编码一遍。这不只是编码本身的问题,它还导致某些本身不需要编码的值也被转了一遍。更隐蔽的是,这个Filter编码后没有设置Cookie的Path,默认Path是当前请求路径,于是这个Cookie只在部分页面有效,跳到别的目录就取不到了。

最后我的修法很简单:撤销全局Filter中对Cookie的处理,改成在用户登录Controller里只对特定Cookie值做显式URLEncoder,读取时用自定义EL函数统一解码。改动不大,但之后再也没有出现过Cookie中文乱码的工单。这个过程让我深刻体会到,Cookie乱码问题通常不是一个技术难点,而是一个“谁都在管、谁都没管透”的协作混乱问题。找到源头、收敛逻辑,一次性解决,比反复在页面上打补丁靠谱得多。

6. 梳理一下,能做到什么程度

把Cookie中的中文值彻底整明白,核心其实就三句话:写入前编码、读取时解码、解码环节要收口。写入前编码保证了Cookie字符集的合规性,读取时解码保证了页面上能看到真实中文,收口到固定的Java代码或EL函数里保证了全项目行为一致。按照方案一或方案二落地,几十行代码就能搞定,顺手还能把其他奇奇怪怪的“双编码”“漏编码”问题一并暴露出来。

我个人在实际操作中的体会是:这个问题的排查价值不亚于修复价值。很多人一遇到乱码,第一反应就是这里加个转码、那里加个过滤,反而把链路越改越乱。真正有效的办法始终是先观察浏览器端的Cookie原文,再反推服务端每一步对它做了什么。Cookie不是业务数据的好载体,存一些短生命周期的用户标识、开关状态、回跳路径就够了,一旦发现需要往Cookie里塞大段中文内容,就该认真考虑是不是该改用Session或服务端缓存了。能不在Cookie里放中文,才是对最终用户最负责任的做法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询