在 Linux 运行了 Windows 上写的程序,出现了 Windows 平台没有出现的乱码问题,这里记录一下解决的想法与过程

1. 字符集转换前后都乱码

注: 以下环境和输出都是 utf-8

总结: Java 在获取当前系统的文件夹下所有文件时是依赖与系统实现的。

坑: 目标服务器是 zh_CN.UTF-8,而设备通过 FTP 上传到服务器的文件名的字符集是 GBK,这就意味在获取文件名的时候需要注意对文件名进行转码,这看似简单的一步缺暗藏两个玄机。

1-1. 字符集转换不可逆

注: 并非所有字符集的转换都不可逆

首先是第一个小坑,字符集的转换,特别是 GBKUTF-8 的转换,因为他们两个对每个字的字节长度定义不同,一个是两字节的定长长度,一个是一到三字节的不定长长度(汉字一般是三字节),这就使得只有正确的解析才能将两个字符集之间的内容进行转换,一旦错误的解析(例如对字符集为 UTF-8 的内容用字符集 gbk 来进行解析,就基本上回不去了)
这个坑在理解字符集转换的原理之前可能会感到很费解,例如一开始我就知道目标文件的字符集是 GBK,所以当我发现我用 UTF-8 启动的项目拿到文件名就已经是乱码的时候,是意料之中,所以我对它进行了从 GBK 转 UTF-8 的操作,结果是乱码,仔细对比转换之前的输出发现并不是 GBK(输出到日志并用 GBK 对文件名进行解析,发现还是失败),这时候我突然发现我拿到的文件名是 GBK 用 UTF-8 的编码格式转换出来的文件名。就好比在我拿到这个文件名之前,有人对它进行了如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
try {
// 这个是我以为我能拿到的,GBK 的文件名
String fileNameGBK = new String("文件名".getBytes("UTF-8"), "GBK");
// 结果我拿到的是 GBK 被强行用 UTF-8 解析后的结果
String resultFileName = new String(fileNameGBK.getBytes("UTF-8"), "GBK");
System.out.println("GBK:" + fileNameGBK);
System.out.println("用UTF-8解析后GBK后的UTF-8:" + resultFileName);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}

输出结果如下:

1
2
GBK: 鏂囦欢鍚�
用UTF-8解析后GBK后的UTF-8: 閺傚洣娆㈤崥锟�

当我发现到手的字符集是被处理过的时候,我马上就开始了反向转换,而这时候,我压根就没想到还有数据丢失,大致的流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
try {
// 这个是我以为我能拿到的,GBK 的文件名
String fileNameGBK = new String("文件名".getBytes("UTF-8"), "GBK");
// 结果我拿到的是 GBK 被强行用 UTF-8 解析后的结果
String resultFileName = new String(fileNameGBK.getBytes("UTF-8"), "GBK");
System.out.println("GBK: " + fileNameGBK);
System.out.println("用UTF-8解析后GBK后的UTF-8: " + resultFileName);
// 反向转换
String gbkTo = new String(resultFileName.getBytes("GBK"), "UTF-8");
// 将反向转换后的结果用 gbk 解析转成 utf-8
String utfTo = new String(gbkTo.getBytes("GBK"), "UTF-8");
System.out.println("逆向转换: " + gbkTo);
System.out.println("正确解析: " + utfTo);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}

输出结果如下:

1
2
3
4
GBK: 鏂囦欢鍚�
用UTF-8解析后GBK后的UTF-8: 閺傚洣娆㈤崥锟�
逆向转换: 鏂囦欢鍚�?
正确解析: 文件�??

这里我们可以看到,当进行逆向的转换时,乱码后面出现了一个?号,然后再解析成 utf-8 的时候,不可避免的丢失了最后一个字符,但是这不是绝对的,如果我们把一开始的 文件名 改为 文1件2名3,这时候就是毁灭性的了,这个时候的输出结果如下:

1
2
3
4
GBK: 鏂�1浠�2鍚�3
用UTF-8解析后GBK后的UTF-8: 閺傦拷1娴狅拷2閸氾拷3
逆向转换: 鏂�1浠�2鍚�3
正确解析: �?1�?2�?3

可以看到的是,当之后中文的时候,只是丢失了最后一个汉字,但是当这段汉字里面夹杂着数字或者字母的时候,毁灭性的一幕就出现了,全部汉字都变成了乱码,真的没救了。
当然,造成这种结果的原因很简单,就在于 GBK 和 UTF-8 的编码格式不一样
GBK 是定长的两字节长度,而 UTF-8 为了节省空间,是不定长的,数字和字母只需要一个字节长度就能装下,但是汉字需要三个字节长度,所以当一个汉字和一个数字一前一后放在一起的时候,GBK 第四个字节的内容里面放的是数字和字母,前面使用一个固定的内容作为标识,所以 utf-8 在解析第四个字节的时候能够成功解析为数字,但是前三个字节就会被解析成两个乱码。并且因为汉字的值在 GBK 里面也是比较大, utf-8 需要把自己扩充为三个字节的长度来适应汉字,所以整个的内容就全变了。
我们不妨把从前到后的四个字段都转成 byte 数组,那么就会明显上很多
我们先转换最初的两个,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(String[] args) {
try {
// 这个是我以为我能拿到的,GBK 的文件名
String fileNameGBK = new String("文1件2名3".getBytes("UTF-8"), "GBK");
// 结果我拿到的是 GBK 被强行用 UTF-8 解析后的结果
String resultFileName = new String(fileNameGBK.getBytes("UTF-8"), "GBK");
System.out.println("GBK:" + fileNameGBK);
System.out.println("用UTF-8解析后GBK后的UTF-8:" + resultFileName);
byte[] fileNameGBKBytes = fileNameGBK.getBytes("GBK");
byte[] resultFileNameBytes = resultFileName.getBytes("GBK");
for(byte b : fileNameGBKBytes) {
System.out.print("| " + b + " ");
}
System.out.println("|");
for (byte b : resultFileNameBytes) {
System.out.print("| " + b + " ");
}
System.out.println("|");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}

输出结果如下:

1
2
3
4
GBK: 鏂�1浠�1鍚�1
用UTF-8解析后GBK后的UTF-8: 閺傦拷1娴狅拷1閸氾拷1
| -26 | -106 | 63 | 49 | -28 | -69 | 63 | 50 | -27 | -112 | 63 | 51 |
| -23 | -113 | -126 | -17 | -65 | -67 | 49 | -26 | -75 | -96 | -17 | -65 | -67 | 50 | -23 | -115 | -102 | -17 | -65 | -67 | 51 |

这里虽然 GBK 的输出看起来是有9个字,但是这个控制台也是 utf-8,所以这个乱码是已经没救的乱码
通过观察第一行 byte[] 可知 gbk 的三个字的确是六个字节的长度,并且可以发现,63|49 和 63|50 和 63|51 分别代表了1、2、3
而第二行长达21个字节长度的乱码,其实用 gbk 已经无法解析了,但是用 utf-8 却可以解析,除了单个字节就能代表的49、50、51分别指代了1、2、3,其他在utf-8里面都是三个字节作为一个乱码存在,所以这里应该是18个字节的汉字,共六个,三个字节的数字,共三个,一共9个字,刚好和 gbk 输出在控制台的乱码一样。
所以我们可以发现这时候如果想转回去,凭空多出来三个字,并且这三个字都是 -17|-65|-67 ,也都恰好在数字的前面。
所以基本上这三个汉字都已经救不回来了。
这里把恢复的过程也放出来,这样就简单明了多了,不过还是要懂得字符集相关的知识理解起来会快上很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public static void main(String[] args) {
try {
// 这个是我以为我能拿到的,GBK 的文件名
String fileNameGBK = new String("文1件2名3".getBytes("UTF-8"), "GBK");
// 结果我拿到的是 GBK 被强行用 UTF-8 解析后的结果
String resultFileName = new String(fileNameGBK.getBytes("UTF-8"), "GBK");
System.out.println("GBK: " + fileNameGBK);
System.out.println("用UTF-8解析后GBK后的UTF-8: " + resultFileName);
// 反向转换
String gbkTo = new String(resultFileName.getBytes("GBK"), "UTF-8");
// 将反向转换后的结果用 gbk 解析转成 utf-8
String utfTo = new String(gbkTo.getBytes("GBK"), "UTF-8");
System.out.println("逆向转换: " + gbkTo);
System.out.println("正确解析: " + utfTo);
byte[] fileNameGBKBytes = fileNameGBK.getBytes("GBK");
byte[] resultFileNameBytes = resultFileName.getBytes("GBK");
byte[] gbkToBytes = gbkTo.getBytes("UTF-8");
byte[] utfToBytes = utfTo.getBytes("UTF-8");
for(byte b : fileNameGBKBytes) {
System.out.print("| " + b + " ");
}
System.out.println("|");
for (byte b : resultFileNameBytes) {
System.out.print("| " + b + " ");
}
System.out.println("|");
for (byte b : gbkToBytes) {
System.out.print("| " + b + " ");
}
System.out.println("|");
for (byte b : utfToBytes) {
System.out.print("| " + b + " ");
}
System.out.println("|");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}

输出结果如下:

1
2
3
4
5
6
7
8
GBK: 鏂�1浠�2鍚�3
用UTF-8解析后GBK后的UTF-8: 閺傦拷1娴狅拷2閸氾拷3
逆向转换: 鏂�1浠�2鍚�3
正确解析: �?1�?2�?3
| -26 | -106 | 63 | 49 | -28 | -69 | 63 | 50 | -27 | -112 | 63 | 51 |
| -23 | -113 | -126 | -17 | -65 | -67 | 49 | -26 | -75 | -96 | -17 | -65 | -67 | 50 | -23 | -115 | -102 | -17 | -65 | -67 | 51 |
| -23 | -113 | -126 | -17 | -65 | -67 | 49 | -26 | -75 | -96 | -17 | -65 | -67 | 50 | -23 | -115 | -102 | -17 | -65 | -67 | 51 |
| -17 | -65 | -67 | 63 | 49 | -17 | -65 | -67 | 63 | 50 | -17 | -65 | -67 | 63 | 51 |

可以看到在最后的时候,出现了一模一样的乱码,这种情况是因为 utf-8 里面的乱码在 gbk 里面并没有对应的,所以出现了这个乱码,所以我们基本上可以确定的是,一旦第一步错了,后面就基本没办法修复数据了。
所以在拿到数据发现不是 GBK 的时候,应该反应过来不要尝试反向出来,应该想办法解决拿到就是被处理过的数据的这个问题。

1-2. 系统提供的文件信息

我最开始是使用 Java 原生提供的获取文件目录的方法 java.io.File.getFiles(),它返回了一个文件数组,但是,这个时候的文件名已经是不可修复的乱码了,所以我们得解决这个问题。
首先我们得知道为什么拿到手的数据就已经是乱码了
这个是因为 Java 获取文件目录并不是虚拟机 jvm 去读取的,所以不管你怎么修改项目的默认字符集,jvm启动时指定任何的字符集,在获取文件列表这个行为上,是 jvm 访问系统,拿到系统返回的数组,这应该是用 Java 自带的 dll 或者是 so 提供的功能,而他们统一使用当前系统的字符集
所以这个时候应该修改当前系统的字符集,这里我是用的是 CentOS7,所以只需要修改 /etc/locale.conf,将 LANG 这一项改为 zh_SG.gbk就行了,大致内容如下:

1
2
#LANG="zh_CN.utf-8"
LANG="zh_SG.gbk"

再 souce 一下配置文件就可以立即生效了,未生效也可以先退出 ssh 重新登录。

1
source /etc/locale.conf

当然,最好先使用 locale -a 查看一下当前系统支持的语言,不支持可以先安装后修改。