写在前面
网上很多的文章都是教科书式的说教,缺乏实用价值。这也是笔者想写此系列文章的初衷,希望把实际工作的实战经验分享给大家,帮助大家解决实际问题。后续的一系列文章都是笔者在实际工作遇到的问题,比较具有代表性,从实战的角度进行分析总结,希望能够给大家带来帮助。
一、问题背景
老套路,先说一下问题的背景,然后再展开讨论。
笔者日前在参与一个网上商城的项目,使用Java语言、Spring Boot框架、Mysql数据库、MyBatis作为OR Mapping工具。其中一个功能是通过第三方的API接口向发货商的ERP系统推送订单信息。调用第三方API接口时需要对请求参数进行签名,第三方服务器会对该请求参数进行合法性验证。这也是大多数第三方接口调用的通用方式,通过使用第三方提供的SECRET和请求参数拼接,然后对拼接字符串进行MD5签名。第三方要求请求服务时统一使用UTF-8编码,需要对url及入参进行 encoding,post方式需要设置request里的Content-Type为 application/x-www-form-urlencoded。
完全遵循第三方的要求进行签名与请求发送,在开发机上一切正常,调用接口没有任何问题。本以为万事大吉,满怀信心地交给测试人员进行测试,优哉优哉了!没成想,测试同学直接一个bug扔回来,推送订单失败!不应该呀!一脸的懵圈中……,按照开发人员的惯性思维,肯定是你的测试数据有问题,很不服气的样子!但是作为一个负责任的老码农,不能无端的指责别人,先看看什么原因再下结论。先把服务器上的log翻出来看了一下,服务器端返回的错误说明是:签名验证失败!不可能啊!赶紧在自己的开发机上用同样的数据请求了一遍,成功了!没有问题呀!怎么在服务器上就签名失败呢?肯定是服务器配置的问题,又犯老毛病了:)。是不是服务器配置的问题,要找到证据才有说服力。
二、问题定位
还是不死心,又在开发机和服务器试了几遍之后,终于接受现实了。这个问题怎们排查呢?同样的数据调用同样的签名代码,却在不同的机器上得到不同的结果。服务器上面没有办法debug,那就把log打印出来对比一下喽!在代码中打印出需要签名的字符串以及得到的签名结果,在开发机和服务器上分别运行。很快就发现问题了,因为需要签名的字符串中包含中文字符,在我的开发机上打印正常,可是在服务器上中文部分打印出来的却是乱码,这应该就是问题的原因所在,基本可以确定是因为编码不一致引起的。
三、解决方案
定位到问题的原因就好办了,首先我们开发过程中明确要求字符编码统一使用UTF-8,并且第三方也是明确要求使用UTF-8编码的,所以在我开发机的Java启动项中有-Dfile.encoding=UTF-8设置,而服务器上部署时却没有加该配置项。那服务器部署时加上这个配置项不就解决问题了?是的,没错!如果仅到此为止,那就没意思了。只是停留在解决问题的层面,终究不是学习之道。本着寻根问底的科学精神,我们要继续探索更深层次的原因。
四、深入分析
又把代码过了一遍,发现整个签名流程当中和字符编码有关的步骤可能发生在MD5计算的过程中,于是找到相关代码,如下:
public static String getMD5(String source) throws NoSuchAlgorithmException, UnsupportedEncodingException {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(source.getBytes());
byte[] md5Bytes = md.digest();
StringBuilder res = new StringBuilder();
for (int i = 0; i < md5Bytes.length; i++){
int t = md5Bytes[i] & 0xff;
if (t <= 0xf){
res.append("0");
}
res.append(Integer.toHexString(t));
}
return res.toString();
}
这里有个步骤是先获取待签名字符串的字节码,经过计算之后再转成字符串。md.update(source.getBytes());这行代码用来获取字符串的字节数组,应该和字符编码有关。找出其源代码来看看:
/**
* Encodes this {@code String} into a sequence of bytes using the
* platform's default charset, storing the result into a new byte array.
*
* <p> The behavior of this method when this string cannot be encoded in
* the default charset is unspecified. The {@link
* java.nio.charset.CharsetEncoder} class should be used when more control
* over the encoding process is required.
*
* @return The resultant byte array
*
* @since JDK1.1
*/
public byte[] getBytes() {
return StringCoding.encode(value, 0, value.length);
}
果不其然,不带参数的getBytes方法会使用平台默认的字符集(the platform’s default charset)把字符串编码为字节序列。这个平台默认的字符集是什么?继续深挖下去,查看StringCoding.encode方法的源代码:
static byte[] encode(char[] ca, int off, int len) {
String csn = Charset.defaultCharset().name();
try {
// use charset name encode() variant which provides caching.
return encode(csn, ca, off, len);
} catch (UnsupportedEncodingException x) {
warnUnsupportedCharset(csn);
}
try {
return encode("ISO-8859-1", ca, off, len);
} catch (UnsupportedEncodingException x) {
// If this code is hit during VM initialization, MessageUtils is
// the only way we will be able to get any kind of error message.
MessageUtils.err("ISO-8859-1 charset not available: "
+ x.toString());
// If we can not find ISO-8859-1 (a required encoding) then things
// are seriously wrong with the installation.
System.exit(1);
return null;
}
}
首先使用系统默认的字符集(Charset.defaultCharset)进行编码,系统不支持该字符集时,就改用ISO-8859-1字符集进行编码,如果连ISO-8859-1字符集都不支持的话,虚拟机就直接退出了。
Charset.defaultCharset到底取的是那个值呢?再往下追:
private static volatile Charset defaultCharset;
/**
* Returns the default charset of this Java virtual machine.
*
* <p> The default charset is determined during virtual-machine startup and
* typically depends upon the locale and charset of the underlying
* operating system.
*
* @return A charset object for the default charset
*
* @since 1.5
*/
public static Charset defaultCharset() {
if (defaultCharset == null) {
synchronized (Charset.class) {
String csn = AccessController.doPrivileged(
new GetPropertyAction("file.encoding"));
Charset cs = lookup(csn);
if (cs != null)
defaultCharset = cs;
else
defaultCharset = forName("UTF-8");
}
}
return defaultCharset;
}
妖猴哪里走?还不显出原形!
这段注释说的很明确了,简单说就是默认字符集是在java虚拟机启动时确定的,依赖于java虚拟机所在的操作系统的时区以及字符集。取值是file.encoding选项配置的字符集,如果找不到file.encoding选项配置的字符集,就使用UTF-8字符集。
对比一下服务器和我的开发机的locale信息:
服务器:/data/services$ locale
LANG=
LANGUAGE=
LC_CTYPE="POSIX"
LC_NUMERIC="POSIX"
LC_TIME="POSIX"
LC_COLLATE="POSIX"
LC_MONETARY="POSIX"
LC_MESSAGES="POSIX"
LC_PAPER="POSIX"
LC_NAME="POSIX"
LC_ADDRESS="POSIX"
LC_TELEPHONE="POSIX"
LC_MEASUREMENT="POSIX"
LC_IDENTIFICATION="POSIX"
LC_ALL=
服务器locale的LANG属性未设置。再看看我的开发机:
Last login: Tue Jun 25 10:01:01 on console
(base) weichenglideMacBook-Pro:~ weichengli$ locale
LANG="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
LC_CTYPE="zh_CN.UTF-8"
LC_MESSAGES="zh_CN.UTF-8"
LC_MONETARY="zh_CN.UTF-8"
LC_NUMERIC="zh_CN.UTF-8"
LC_TIME="zh_CN.UTF-8"
LC_ALL=
(base) weichenglideMacBook-Pro:~ weichengli$
我的开发机设置为UTF-8字符集。
到现在还是没有弄清楚服务器上到底使用的什么字符集。从Jenkins部署的log中可以清晰地看到服务器的默认编码:
Maven home: /home/jenkins/tools/hudson.tasks.Maven_MavenInstallation/Maven_3
Java version: 1.8.0_181, vendor: Oracle Corporation, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: en_US, platform encoding: ANSI_X3.4-1968
OS name: "linux", version: "4.20.12-1.el7.elrepo.x86_64", arch: "amd64", family: "unix"
platform encoding: ANSI_X3.4-1968,一种工程字符编码,这里不详细介绍。到这里的话,基本上就搞清楚了错误的原因,如果在服务器上java启动的时候不指定file.encoding选项的话,服务器上启动的JVM就默认采用了ANSI_X3.4-1968编码,导致MD5签名失败。
除了在JVM启动参数里面加-Dfile.encoding=UTF-8这个选项外,也可以修改代码来解决这个问题,就是在MD5签名获取字节数组时指定UTF-8编码,这样更加明确。
public static String getMD5(String source) throws NoSuchAlgorithmException, UnsupportedEncodingException {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(source.getBytes("UTF-8"));
byte[] md5Bytes = md.digest();
StringBuilder res = new StringBuilder();
for (int i = 0; i < md5Bytes.length; i++){
int t = md5Bytes[i] & 0xff;
if (t <= 0xf){
res.append("0");
}
res.append(Integer.toHexString(t));
}
return res.toString();
}
source.getBytes("UTF-8")直接指定UTF-8编码。
五、扩展思考
科学精神还没有结束,还得继续探索下去。
String的getBytes()方法,如果你在使用这个方法时不考虑编码这一点,你会发现在一个平台上运行良好的系统,放到另一台机器上会产生意想不到的问题。该方法是和平台(编码)相关的。
下面我们再来做一个试验,在我自己的开发机上进行。我的开发机上操作系统的默认字符集为UTF-8。该试验的目的是检验一下file.encoding选项与Charset.defaultCharset的关系。
private static volatile Charset defaultCharset;
/**
* Returns the default charset of this Java virtual machine.
*
* <p> The default charset is determined during virtual-machine startup and
* typically depends upon the locale and charset of the underlying
* operating system.
*
* @return A charset object for the default charset
*
* @since 1.5
*/
public static Charset defaultCharset() {
if (defaultCharset == null) {
略
}
return defaultCharset;
}
从前面的分析我们知道默认字符集(defaultCharset)是在java虚拟机启动时确定的,依赖于java虚拟机所在的操作系统的时区以及字符集。其实这个默认字符集(defaultCharset)确切的说应该是JVM(虚拟机)的字符集,而且defaultCharset这个变量是个private的static变量。按照以上代码的逻辑这个defaultCharset设置之后就不会再更改了,直到JVM退出。只有在defaultCharset等于null的时候才会取file.encoding的值进行设置,之后即使在JVM运行期间通过System.setProperty()方法修改了file.encoding的值,defaultCharset也不会跟着改变。
通过以下代码来验证一下喽!
import java.nio.charset.Charset;
public class Test {
public static void main(String[] args) {
System.out.println(System.getProperty("file.encoding"));
System.out.println(Charset.defaultCharset().name());
System.out.println(Charset.defaultCharset().displayName());
System.setProperty("file.encoding", "GBK");
System.out.println("Change the value of file.encoding");
System.out.println(System.getProperty("file.encoding"));
System.out.println(Charset.defaultCharset().name());
System.out.println(Charset.defaultCharset().displayName());
}
}
编译:javac Test.java
运行:java Test
输出结果为:
(base) weichenglideMacBook-Pro:Desktop weichengli$ java Test
UTF-8
UTF-8
UTF-8
Change the value of file.encoding
GBK
UTF-8
UTF-8
(base) weichenglideMacBook-Pro:Desktop weichengli$
改变一下配置再运行:java –Dfile.encoding=GB2312 Test
输出结果为:
(base) weichenglideMacBook-Pro:Desktop weichengli$ java -Dfile.encoding=GB2312 Test
GB2312
GB2312
GB2312
Change the value of file.encoding
GBK
GB2312
GB2312
(base) weichenglideMacBook-Pro:Desktop weichengli$
通过这个试验说明了file.encoding选项与Charset.defaultCharset的关系。
除了getBytes()方法使用defaultCharset以外,字符流的读取和写入同样使用了该属性。还是以代码为证:
/**
* Creates an InputStreamReader that uses the default charset.
*
* @param in An InputStream
*/
public InputStreamReader(InputStream in) {
super(in);
try {
sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
} catch (UnsupportedEncodingException e) {
// The default encoding should always be available
throw new Error(e);
}
}
InputStreamReader调用StreamDecoder.forInputStreamReader方法:
public static StreamDecoder forInputStreamReader(InputStream var0, Object var1, String var2) throws UnsupportedEncodingException {
String var3 = var2;
if (var2 == null) {
var3 = Charset.defaultCharset().name();
}
try {
if (Charset.isSupported(var3)) {
return new StreamDecoder(var0, var1, Charset.forName(var3));
}
} catch (IllegalCharsetNameException var5) {
}
throw new UnsupportedEncodingException(var3);
}
妥妥滴!如果不指定编码的话,还是使用Charset.defaultCharset。
好了,到这里,就到这里,再见吧!
2019年6月27日晚 于北京通州家中









网友评论