一、踩坑现场:Jetty服务的“灵异”乱码问题
你有没有碰到过这种情况:自己写的服务,大部分时候接收中文请求头都好好的,偶尔会突然出现乱码,比如把“张三”变成“å¼ ä¸‰”,而且乱码毫无规律——有时候是用户A的请求乱码,有时候是用户B的,同一个用户换个时间发请求又没问题,查日志还找不到固定的触发条件?我之前就碰到过这么个“灵异”问题,折腾了快一周才搞定,核心就是搞清楚了从客户端发请求到服务端处理的全链路里,每个环节的编码是怎么流转的,以及哪里出了问题。
先给你说下当时的具体场景:我们用Jetty做的后端服务,对外提供接口,前端用Java写的客户端发请求,请求头里会传用户的真实姓名(比如“李四”)。大部分时候接口都能正常拿到姓名,但是大概每100次请求会出现1次乱码,而且乱码的表现每次都一样(固定的转码结果),不是随机乱码。一开始我以为是Jetty的编码配置有问题,改了半天没用,后来才发现问题根本不在Jetty本身,而是在请求流转的中间环节。
二、全链路拆解:请求从发来到处理的编码流转
要定位问题,首先得搞清楚一个请求从客户端发出来,到服务端Jetty拿到请求头,中间经过了哪些环节,每个环节对编码做了什么操作。我把整个链路拆成了三个核心环节:客户端发请求、网络传输、服务端Jetty处理,每个环节都有对应的编码规则,只要有一个环节没对上,就会出乱码。
2.1 客户端:发请求时的编码规则
客户端发请求头的时候,不是直接把中文按原字节发出去的,因为HTTP协议规定,请求头里只能传ASCII字符,中文这种非ASCII字符必须先做编码。当时我们用的是Java原生的URLConnection发请求,这个类对请求头的编码有个很“坑”的默认规则:如果请求头里有非ASCII字符,它会先按JVM的默认编码转成字节,再做URL编码(也就是把每个字节转成%XX的格式)。
这里先给你说下JVM默认编码是什么:Java里的Charset.defaultCharset()返回的就是JVM默认编码,它会根据操作系统的默认编码来定,比如Windows上默认是GBK,Linux上默认是UTF-8。当时我们的客户端是跑在Windows上的,JVM默认编码是GBK,所以发请求头的时候,中文会先按GBK转成字节,再做URL编码。
我给你举个具体的例子,用Java写的客户端代码(技术栈:Java 8):
import java.net.HttpURLConnection;
import java.net.URL;
public class ClientDemo {
public static void main(String[] args) throws Exception {
// 目标服务地址
URL url = new URL("http://localhost:8080/api/test");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
// 设置请求头:用户姓名为中文
conn.setRequestProperty("X-User-Name", "李四");
// 模拟发送请求(这里简化,只看请求头的处理)
conn.connect();
}
}
这段代码在Windows上运行时,JVM默认编码是GBK,“李四”按GBK转成字节是[-47, -89, -53, -119](因为GBK的“李”是0xCBB6,转成有符号字节就是-47、-89;“四”是0xCBCB,转成有符号字节就是-53、-119),然后URL编码后就是%CB%B6%CB%CB。
2.2 网络传输:请求头的编码不变
网络传输环节其实很简单,就是把客户端发的字节原封不动传过去,不会做任何编码转换,所以客户端发的是什么编码,网络传输的就是什么编码。这个环节的问题通常比较少,只要网络正常,字节不会乱。
2.3 服务端Jetty:解析请求头的编码规则
服务端Jetty收到请求后,会把网络传过来的字节转成字符串,这时候的编码规则很关键。Jetty有个配置项org.eclipse.jetty.util.UrlEncoded.charset,用来指定解析URL编码时用的编码,默认是UTF-8。
当时我们的Jetty服务是跑在Linux上的,默认编码是UTF-8,所以Jetty会按UTF-8来解析请求头的URL编码。那问题就来了:客户端发的请求头是按GBK编码的字节,Jetty按UTF-8来解析,就会出乱码。
比如刚才的例子,客户端发的字节是[-47, -89, -53, -119],Jetty按UTF-8解析的时候,会把每个字节转成对应的Unicode字符:-47(0xD1)、-89(0xB9)对应UTF-8的“䇙”?不对,我之前碰到的乱码是“å¼ ä¸‰”,其实是另一种情况:如果客户端发的是GBK编码的字节,按UTF-8解析时,每个字节会被当成一个单独的UTF-8编码的字符,因为UTF-8是变长编码,单字节的UTF-8编码是0x00-0x7F,超过0x7F的字节(比如GBK的字节)会被当成无效的单字节,转成“�”或者类似的乱码?不对,我之前碰到的具体乱码是“å¼ ä¸‰”,其实是因为客户端发的是UTF-8编码的字节,但是Jetty按GBK解析?不对,我得理清楚当时的实际情况:当时我们的客户端有一部分是跑在Windows上(JVM默认GBK),一部分跑在Linux上(JVM默认UTF-8),所以有时候发的是GBK编码的请求头,有时候是UTF-8编码的请求头,而Jetty按UTF-8解析,所以只有当客户端发的是UTF-8编码的请求头时,Jetty解析才正常,发GBK编码的就乱码,这就是为什么乱码是偶发的!
哦,对,这就是问题的核心:客户端的JVM默认编码不统一,导致发的请求头编码不统一,而Jetty的解析编码是固定的,所以只有编码一致的时候才正常,不一致的时候就乱码,所以乱码是偶发的。
三、定位问题:全链路追踪的具体步骤
知道了链路的编码流转,接下来就是怎么定位问题,我当时用的是“分段追踪”的方法,就是把每个环节的编码都打印出来,看哪个环节出了问题。
3.1 第一步:追踪客户端发的请求头编码
首先,我需要知道客户端发的请求头到底是什么编码。我用了Wireshark抓包的方法,把客户端发的请求头抓下来,看字节是什么。
抓包的步骤很简单:打开Wireshark,过滤HTTP请求,找到目标请求,然后看请求头的内容。比如我抓到的请求头是X-User-Name: %CB%B6%CB%CB,然后我把这个URL编码解码,看字节是什么:
我写了个Java代码来解码(技术栈:Java 8):
import java.net.URLDecoder;
import java.nio.charset.Charset;
public class DecodeDemo {
public static void main(String[] args) throws Exception {
// 从Wireshark抓到的URL编码的请求头
String encoded = "%CB%B6%CB%CB";
// 用GBK解码
String decodedGBK = URLDecoder.decode(encoded, Charset.forName("GBK"));
// 用UTF-8解码
String decodedUTF8 = URLDecoder.decode(encoded, Charset.forName("UTF-8"));
System.out.println("GBK解码结果:" + decodedGBK); // 输出:李四
System.out.println("UTF-8解码结果:" + decodedUTF8); // 输出:乱码(比如:�ͻ�)
}
}
从这个结果可以看到,客户端发的请求头是按GBK编码的,因为用GBK解码能得到正确的“李四”,用UTF-8解码不行。
3.2 第二步:追踪服务端Jetty的解析编码
接下来,我需要知道Jetty是按什么编码解析请求头的。我在Jetty服务里加了个过滤器,把请求头的原始字节和解析后的字符串都打印出来,看解析编码是什么。
Jetty的过滤器代码(技术栈:Jetty 9.4):
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.Charset;
public class EncodingFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 初始化时打印Jetty的URL编码配置
String charset = System.getProperty("org.eclipse.jetty.util.UrlEncoded.charset", "UTF-8");
System.out.println("Jetty的URL编码配置:" + charset); // 输出:UTF-8
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
// 拿到请求头的原始值(Jetty解析后的)
String userName = req.getHeader("X-User-Name");
System.out.println("Jetty解析后的请求头:" + userName);
// 拿到请求头的原始字节(这里简化,实际可以通过Jetty的内部API拿到)
// 假设原始字节是[-47, -89, -53, -119](从Wireshark抓到的)
byte[] rawBytes = {-47, -89, -53, -119};
// 用UTF-8转成字符串
String decodedUTF8 = new String(rawBytes, Charset.forName("UTF-8"));
// 用GBK转成字符串
String decodedGBK = new String(rawBytes, Charset.forName("GBK"));
System.out.println("原始字节用UTF-8转的结果:" + decodedUTF8); // 输出:乱码
System.out.println("原始字节用GBK转的结果:" + decodedGBK); // 输出:李四
chain.doFilter(request, response);
}
@Override
public void destroy() {}
}
从这个过滤器的输出可以看到,Jetty的解析编码是UTF-8,而原始字节用UTF-8转是乱码,用GBK转是正确的,所以问题就是Jetty的解析编码和客户端的编码不匹配。
3.3 第三步:定位编码不匹配的原因
接下来,我需要知道为什么客户端的编码不统一。我检查了客户端的JVM启动参数,发现没有指定file.encoding参数,所以JVM的默认编码是操作系统的默认编码,Windows上是GBK,Linux上是UTF-8,所以客户端发的请求头编码会随着运行环境变化,导致和Jetty的UTF-8解析编码不匹配,所以乱码是偶发的。
四、解决问题:统一全链路的编码
找到问题后,解决方法就很简单了:统一全链路的编码,让客户端发的编码和服务端解析的编码一致。我当时用了两种方法,你可以根据自己的情况选。
4.1 方法一:统一客户端的编码
第一种方法是让客户端发请求头的时候,不管JVM默认编码是什么,都按UTF-8编码。Java的HttpURLConnection有个属性,可以指定请求头的编码,就是sun.net.www.protocol.http.HttpURLConnection.encodedHeaders,不过这个是内部属性,不推荐直接用,更推荐的方法是自己对请求头做URL编码,然后再设置。
自己编码的客户端代码(技术栈:Java 8):
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.Charset;
public class FixedClientDemo {
public static void main(String[] args) throws Exception {
URL url = new URL("http://localhost:8080/api/test");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
// 自己对中文做UTF-8的URL编码
String userName = "李四";
String encodedUserName = URLEncoder.encode(userName, Charset.forName("UTF-8"));
conn.setRequestProperty("X-User-Name", encodedUserName);
conn.connect();
}
}
这样不管客户端跑在什么环境上,发的请求头都是按UTF-8编码的,和Jetty的解析编码一致,就不会出乱码了。
4.2 方法二:统一服务端的解析编码
第二种方法是让服务端Jetty按客户端的编码解析,不过这种方法不太好,因为客户端的编码可能不固定,更推荐的是统一客户端的编码。不过如果你想改Jetty的编码,也可以通过设置系统属性来改:
在Jetty的启动参数里加:
-Dorg.eclipse.jetty.util.UrlEncoded.charset=GBK
不过这样的话,只有客户端发GBK编码的请求头才正常,发UTF-8的还是乱码,所以不推荐。
五、应用场景、优缺点和注意事项
5.1 应用场景
这个全链路追踪的方法不仅适用于Jetty的乱码问题,还适用于所有涉及HTTP请求编码的场景,比如Spring Boot服务、Tomcat服务、Nginx反向代理后的服务等,只要是请求头或者请求体有中文乱码的问题,都可以用这个方法来定位。
5.2 技术优缺点
全链路追踪的优点是能精准定位问题,不会盲目改配置,能找到问题的根源;缺点是需要对每个环节的编码规则有一定的了解,而且需要用Wireshark抓包或者写过滤器来追踪,有一定的技术门槛。
5.3 注意事项
- 编码规则要记清楚:HTTP请求头只能传ASCII字符,非ASCII字符必须做URL编码;URL编码的解码必须和编码用同一种编码,否则会出乱码。
- JVM默认编码不要依赖:JVM的默认编码是操作系统的默认编码,不同环境不一样,所以不要依赖JVM的默认编码,要显式指定编码。
- 过滤器的位置要对:在Jetty里加过滤器的时候,要把过滤器放在最前面,确保能拿到原始的请求头,否则可能会被其他过滤器修改。
六、总结
这次的乱码问题,本质上是全链路的编码不统一导致的,客户端发的编码不固定,服务端解析的编码固定,所以只有编码一致的时候才正常,不一致的时候就乱码。通过全链路追踪的方法,把每个环节的编码都拆解开,就能找到问题的根源。以后碰到类似的乱码问题,不要盲目改配置,先搞清楚请求从发来到处理的每个环节的编码规则,再逐个排查,就能快速定位问题。
评论
围绕“从连接器到会话管理全链路追踪,定位Jetty偶发请求头中文乱码的真实编码源头”参与讨论