现在的位置: 首页 > 综合 > 正文

乱码引发的编码思考

2019年11月23日 ⁄ 综合 ⁄ 共 11497字 ⁄ 字号 评论关闭

尊重个人劳动成果,转载请声明:http://blog.csdn.net/softmanfly/article/details/43611985

乱码是软件开发中的常见问题,程序员如果对码不清楚的话经常会被各种码搞得晕头转向,我在开发一个JavaWeb项目时也遇到了一些乱码的问题,百思不得其解,最后通过阅读源码和一定的猜测,对编码和乱码问题有了一定的心得体会,故记录下来(如果只想深入了解Java中的编码相关内容的话可以直接看红字下面的部分):

问题来由:在http get方法中url后面添加query string,使用中文作为参数,提交到服务器导致乱码,比如一个请求:

http://localhost:8080/register?userName=小波波,最后到达服务器时调用request.getParameter("userName")就变成了乱码。

问题分析:

网上查阅了一大堆的方法,有设置charsetEncoding的,有设置URIEncoding的,有new String(params.getBytes("ISO-8859-1"), "utf-8")转化一下的;

然后我就开始各种尝试,发现有时候能扭转乱码,有时候变成其他的乱码,虽然能够解决一时的问题,但是还是不明白本质的原因,所以我决定先从源码入手,看看getParameter函数是如何取得我们需要的参数的值得,以下是getParameter函数的源码:

public String getParameter(String name) {

        parseParameters();

        Object value = parameters.get(name);
        if (value == null)
            return (null);
        else if (value instanceof String[])
            return (((String[]) value)[0]);
        else if (value instanceof String)
            return ((String) value);
        else
            return (value.toString());

    }

发现是在parseParamters里面进行的处理:

<span style="font-size:18px;">if (parsedParams) {
   return;//如果已经解码过,就直接返回
}
parameters = new HashMap<>();
parameters = copyMap(getRequest().getParameterMap());
mergeParameters();</span><pre name="code" class="java"><span style="font-family: Arial, Helvetica, sans-serif;"><span style="font-size:14px;">parsedParams = true;</span></span>


发现应该是继续在mergeParamters里进行处理:

private void mergeParameters() {

        if ((queryParamString == null) || (queryParamString.length() < 1))
            return;

        HashMap<String, String[]> queryParameters = new HashMap<>();
        String encoding = getCharacterEncoding();
        if (encoding == null)
            encoding = "ISO-8859-1";
        RequestUtil.parseParameters(queryParameters, queryParamString,
                encoding);//问题出在这
        Iterator<String> keys = parameters.keySet().iterator();
        while (keys.hasNext()) {
            String key = keys.next();
            Object value = queryParameters.get(key);
            if (value == null) {
                queryParameters.put(key, parameters.get(key));
                continue;
            }
            queryParameters.put
                (key, mergeValues(value, parameters.get(key)));
        }
        parameters = queryParameters;

    }

大致理解以下这个函数,应该是在将通过get方法中?后面的query string携带的参数和通过addParameter方法添加的参数进行合并(merge),所以乱码问题应该来自对queryParameters的处理,也就是RequestUtil.parseParameters函数
打开这个函数:

 public static void parseParameters(Map<String,String[]> map, String data,
            String encoding) {

        if ((data != null) && (data.length() > 0)) {

            // use the specified encoding to extract bytes out of the
            // given string so that the encoding is not lost.
            byte[] bytes = null;
            try {
                bytes = data.getBytes(B2CConverter.getCharset(encoding));
                parseParameters(map, bytes, encoding);
            } catch (UnsupportedEncodingException uee) {
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("requestUtil.parseParameters.uee",
                            encoding), uee);
                }
            }

        }

    }

发现首先将data转化为对应编码的bytes数组(data就是query string也就是?后面的userName=小波波),然后再调用

 parseParameters(map, bytes, encoding);

对byte数组进行处理,那么再次进入这个parseParameters函数:

public static void parseParameters(Map<String,String[]> map, byte[] data,
            String encoding) throws UnsupportedEncodingException {

        Charset charset = B2CConverter.getCharset(encoding);

        if (data != null && data.length > 0) {
            int    ix = 0;
            int    ox = 0;
            String key = null;
            String value = null;
            while (ix < data.length) {
                byte c = data[ix++];
                switch ((char) c) {
                case '&':
                    value = new String(data, 0, ox, charset);
                    if (key != null) {
                        putMapEntry(map, key, value);
                        key = null;
                    }
                    ox = 0;
                    break;
                case '=':
                    if (key == null) {
                        key = new String(data, 0, ox, charset);
                        ox = 0;
                    } else {
                        data[ox++] = c;
                    }
                    break;
                case '+':
                    data[ox++] = (byte)' ';
                    break;
                case '%':
                    data[ox++] = (byte)((convertHexDigit(data[ix++]) << 4)
                                    + convertHexDigit(data[ix++]));
                    break;
                default:
                    data[ox++] = c;
                }
            }
            //The last value does not end in '&'.  So save it now.
            if (key != null) {
                value = new String(data, 0, ox, charset);
                putMapEntry(map, key, value);
            }
        }

大家可以清楚的看到这里就是在进行实际的解析,分别得到参数的key(userName)和value(小波波),然后将其放入到一个Map<Key,Value>中,我们注意到这一行:

 value = new String(data, 0, ox, charset);

这里就是真正将data数组中关于小波波的部分按照charset转化成String value,所以乱码问题的出现应该是这个charset的问题!从上面的源码中我们可以知道charset值是通过这样来设置的:

String encoding = getCharacterEncoding();
        if (encoding == null)
            encoding = "ISO-8859-1";

那么把charset设置成什么编码才不会导致乱码呢?要想搞清楚这个问题还真不容易,你必须得对编码有一个比较清晰的了解,以下内容才是本文的精华,能够让你对Java中的编码有一个很好的认识,以及为什么上面源码中会多次出现ISO8859-1这个编码种类,它有什么特点能够让他在众多的编码方式中脱颖而出成为Java源码中多次用来当做默认的编码:

首先我们来看看Java中的String类,String类理解了,玩转Java中的编码就不是难事了。

String类中有2个比较常用的操作:

一个是str.getBytes(String charsetName)

一个是new String(s.getBytes("GBK"), "UTF-8");

搜索了很多网上的资料,关于这两个操作的深入分析的文章还是很少的,反正我是没找到,所以还是得靠源码来说话,一开始我想既然String和byte数组联系如此紧密,那么String里面一定有2个成员变量把,一个是byte[] bytes负责存放0和1序列,一个是 Charset charset代表bytes的编码种类,假如一个String s="哈";那么在这个String里应该存放着

byte [] bytes = 11011011101...(这里纯属假设,目的是把原理阐述清楚就行) 然后还有Charset charset = Charset.getFromName("UTF-8");这就告诉系统这个String里的byte应该对应UTF-8的编码表进行解码,当系统要显示这个字给用户时,系统就会去UTF-8对应的编码表查找这个特定顺序的01序列,然后找到了“哈”这个字并将它显示出来,一开始认为这么想挺合理的,根据Charset种类的不同,解读bytes的方式也不同,所以当charset不对时,自然就解读不出正确的bytes所表达的内容,造成乱码。
而当调用s.getBytes("GBK")时,这个函数就会把bytes从UTF-8编码像GBK编码转化(我的想象是有一种编码转化的参照表)这样我们就得到了一个GBK编码方式的bytes数组,此时假如我们执行一个这样的操作:s =new String(s.getBytes("GBK"), "GBK"),然后再打印s会同样得到一个“哈”字,只不过此哈非彼哈,这是一个GBK编码的“哈”,他的bytes数组存放的01顺序是按照GBK编码的方式来的,结果我写了一个这样的demo:

package test;

import java.io.UnsupportedEncodingException;

public class TestString {

	public static void main(String[] args) throws UnsupportedEncodingException {
		String s = new String("哈".getBytes("UTF-8"), "GBK");
		System.out.println(s);		
	}
}

期盼他输出哈字,一运行发现乱码了,原本以为我成功的将UTF-8编码的“哈”成功转化成了GBK编码的哈,然后输出也会得到一个GBK编码的哈字呢!可是居然得到的是这货:鍝?

这时候抱着迷惑的心情打开了String的源码,才彻底的拨云见雾,看到庐山真面了,我首先打开了

new String("哈".getBytes("UTF-8"), "GBK");

看看这个构造函数到底在做些什么事情:

public String(byte bytes[], int offset, int length, String charsetName)
	throws UnsupportedEncodingException
    {
	if (charsetName == null)
	    throw new NullPointerException("charsetName");
	checkBounds(bytes, offset, length);
	char[] v = StringCoding.decode(charsetName, bytes, offset, length);
	this.offset = 0;
	this.count = v.length;
	this.value = v;
    }

这才发现String里的根本没有什么byte数组,而是有一个char数组,那么这个char数组是通过

StringCoding.decode(charsetName, bytes, offset, length);

得来的,于是再打开decode函数:

static char[] decode(String charsetName, byte[] ba, int off, int len)
	throws UnsupportedEncodingException
    {
	StringDecoder sd = (StringDecoder)deref(decoder);
	String csn = (charsetName == null) ? "ISO-8859-1" : charsetName;
	if ((sd == null) || !(csn.equals(sd.requestedCharsetName())
			      || csn.equals(sd.charsetName()))) {
	    sd = null;
	    try {
		Charset cs = lookupCharset(csn);
		if (cs != null)
		    sd = new StringDecoder(cs, csn);
	    } catch (IllegalCharsetNameException x) {}
            if (sd == null)
                throw new UnsupportedEncodingException(csn);
	    set(decoder, sd);
	}
	return sd.decode(ba, off, len);
    }

这里有2点值得关注:

1:sd = new StringDecoder(cs, csn);
2:<span style="font-family: Arial, Helvetica, sans-serif;">sd.decode(ba, off, len);</span>

打开StringDecoder的构造函数:

private StringDecoder(Charset cs, String rcn) {
            this.requestedCharsetName = rcn;
	    this.cs = cs;
	    this.cd = cs.newDecoder()
		.onMalformedInput(CodingErrorAction.REPLACE)
		.onUnmappableCharacter(CodingErrorAction.REPLACE);
	}

发现StringDecoder的一个参数cd(CharsetDecoder)是根据cs(Charset)的不同而new出来的Decoder种类也不同,调查后发现这里相当于是一个接口,然后又不同的CharsetDecoder的具体实现,比如UTF8Decoder,GBKDecoder等,所以sd.decode方法应该是具有多态效应的,也就是说要根据不同种类的Decoder实现不同的解码效果,打开decode函数看源码:

static char[] decode(String charsetName, byte[] ba, int off, int len)
	throws UnsupportedEncodingException
    {
	StringDecoder sd = (StringDecoder)deref(decoder);
	String csn = (charsetName == null) ? "ISO-8859-1" : charsetName;
	if ((sd == null) || !(csn.equals(sd.requestedCharsetName())
			      || csn.equals(sd.charsetName()))) {
	    sd = null;
	    try {
		Charset cs = lookupCharset(csn);
		if (cs != null)
		    sd = new StringDecoder(cs, csn);
	    } catch (IllegalCharsetNameException x) {}
            if (sd == null)
                throw new UnsupportedEncodingException(csn);
	    set(decoder, sd);
	}
	return sd.decode(ba, off, len);
    }


可以看到在上面根据cs(Charset)获得了相应的cd(CharsetDecoder)然后调用相应cd的decode函数把一个bb(ByteBuffer)解码为一个cb(CharBuffer)(这里一定要搞清楚byte和char的区别:byte无编码的说法,char有编码);那么我们就会想,为什么要把一个byte数组转成一个char数组呢,这要从java中的char类型说起,java中的char占用2个字节,遵循的是Unicode编码规范(注意规范2个字,这并不是一种具体的编码方式,而是一套规范,告诉你什么样的01序列对应什么符号)那么通过上面的源码解读,我不禁猜想java中的String中的char数组其实存放的是依照Unicode编码规范的01序列,然后系统是根据Unicode中01序列和具体符号的对应关系去显示String中的符号序列的。

那么我们在构造String的时候,其实就是在把按照其他编码方式编码的字符转化成Unicode编码方式的字符,然后用一个Char数组存放在String中,也就是说其他编码方式都可以通过一定的计算手段转化为Unicode编码的01序列,网上一搜资料,果不其然,UTF-8转Unicode只要简单的进行线性替换(不懂的自行搜索学习),而GBK转Unicode也只要进行一定的加减运算就可以得到,所以Unicode可以转化成各种其他的编码,同时各种其他编码也可以向Unicode转化,这样一来就实现了一个统一,这也是Java采用Unicode的原因吧,比如我们在执行如下操作时:

String s = new String(“哈”);其实是在将本机默认编码方式下的“哈”字(假如此时本机的本地默认编码是GBK),从GBK编码通过一定的计算转化成Unicode下“哈”对应的那2个字节,然后放到一个Char数组中,Java对String进行显示时是不用关心你原来的编码方式,因为他们都被统一成了Unicode规范,这样Java就能够根据Char数组去显示“哈”字了,又比如如下操作:String s = new String("哈".getBytes("GBK"),
"UTF-8")产生乱码的原因在于,首先我们获得了GBK编码下的“哈”字的byte数组,里面存放的是GBK编码下“哈”字对应的01序列,然后我们又根据“UTF-8”转化到Unicode的规则对一个本应该依照GBK到Unicode转化规则的byte数组进行了编码转化,使得获得的Char数组失去了本来的意思,而变成了乱码,比如哈字在GBK下编码是110,本来按照GBK到Unicode的转化规则,会转化成011,然后存放到Char数组中,而011在Unicode中正好对应哈这个字,然后此时却按照UTF-8到Unicode的转化规则,错误的转化成了101,这个时候就产生了乱码,更可怕的是有可能110这个序列根本不符合UTF-8的编码规则,这时候就会转化成?号这一类的符号,造成一种黑洞现象,编码被吞了。

所以String中其实存放的实质是:由在其他编码方式下的byte数组按照一定的转化规则转化成Unicode规范后的char数组,而String本身其实不具有Charset属性,也就是说“这个String是什么方式编码”这种说法是错误的,而应该说byte数组遵照什么样的编码方式。

回到最初提到的问题:

为什么Java对ISO8859-1这个编码方式如此情有独钟,动不动就拿来当默认的编解码方式。查询一下ISO8859-1编码相关知识不难知道,这个编码是一个单字节的编码,而且编码范围正好是从0x00-0xFF,涵盖了8个位的所有排列组合的情形,没有一个多余的位(与之相反的一个列子是UTF-8编码,其中有一些0和1的排列组合情况是没有对应任何符号的,比如当UTF-8用2个字节表示一个字符时,开头必须是110,而111这种情况就没有被纳入编码表中),ISO8859-1的一个好处就是,他跟Unicode的互相转换规则非常简单,下面是一个Unicode和ISO8859-1互转的例子:

  1. String-ISO-8859-1〉ByteArray:/u0061/u4E2D/u6587(a中文)-〉0x61 0x3F 0x3F  
  2. ByteArray-ISO-8859-1〉String:0x61 0x3F 0x3F-〉/u0061/u003F/u003F(a??)

可以看到从Unicode转向ISO,如果第一字节如果是0的话,直接留下第二个字节的内容,如果第一字节不是0,那么就超出了ISO所能表达的范围,此时统一将Unicode的char转换成ISO的0x3F

当从ISO转化回来Unicode时,规则就更简单了,只需要在高位添加0,就得到了对应的Unicode Char数组,只可惜的是由于0x3F转化为Unicode是0x003F 也就是?符号,这样就造成了编码转化过程中的编码丢失(类似黑洞吸走了一般),正是基于ISO和Unicode互转非常简单的原理,ISO可以充当任何一种编码和Unicode之间转化的中间态编码,比如下面这个程序:

package test;

import java.io.UnsupportedEncodingException;

import javax.sound.sampled.AudioFormat.Encoding;

public class TestString {

	public static void main(String[] args) throws UnsupportedEncodingException {
		byte [] bytes = "人".getBytes("UTF-8");
		String s1 = new String(bytes, "ISO-8859-1");
		String s2 = new String(s1.getBytes("ISO-8859-1"), "UTF-8");
		System.out.println(s2);
	}
}

1:首先根据UTF-8对“人”这个String里放的Char数组进行编码得到UTF-8编码下的“人”字的byte数组,此时byte中存放的是以下内容:

E4,BA,BA 占用了3个字节的空间

2:然后构造一个s1,利用ISO编码充当中间态,将上面的byte数组按照ISO转Unicode的规则转化成Char数组,这时候s1中存放的char数组内容如下(ISO转Unicode的规则是高位填0):

00E4;00BA;00BA;字节体积扩大了1倍

3:最后将s1中的char数组再由Unicode编码规范转化回ISO编码(规则是高位如果为0,直接去掉高位的那个字节,如果不为0,则超出ISO表达范围,统一转成0x3F),这时候得到的bytes数组存放的是如下内容:

E4,BA, BA 正好是UTF-8编码下的“人”字,此时如果构造一个s2,将这个bytes数组再按照UTF-8转Unicode的规则转成Char数组的话,这个String中的char数组存放的字节序列就是“你”字所对应的Unicode码,最后打印s2得到的就是一个“人”字。

以上就是ISO编码的神奇之处,他能够充当编码互换中的中间态。这也是为什么上面的JDK源码中大量使用ISO编码进行互转的原因。

所以当我们在get方法后面的query string中携带中文时,中文从浏览器被提交到服务器使用的基本上是UTF-8编码,到达服务器后,服务器再使用ISO编码对UTF-8编码转化来的String Char数组进行一下中间处理,如果你要获得正确的中文参数,就需要使用new String(req.getParameter("userName").getBytes("ISO8859-1"),"UTF-8")再转化回遵照UTF-8编码转Unicode规则得到的String,就可以获得带有正确中文含义的参数的值了。


再举些例子帮助理解:


假设本地默认编码是GBK

byte [] bytes = "哈".getBytes();

那么bytes里的01序列就是GBK编码下对应的哈的编码序列

String s = new String(bytes);//没有指明后一个参数charset代表采用本地默认编码进行byte到char的转化

然后bytes又按照GBK到Unicode的转化规则,转化为Unicode下的哈字的01序列存放到Char数组中。

而如果采用如下任何一种方式都会造成乱码(本机默认编码为GBK):

1:byte [] bytes = “哈”.getBytes("UTF-8"); String s = new String(bytes); //乱码原因是bytes是按照UTF-8编码的01序列,而构造String是遵照的是GBK到Unicode的转化方式。

2:byte[] bytes = "哈".getBytes(); String s = new String(bytes, "UTF-8");//打印s输出的是?乱码原因是bytes是按照GBK编码的01序列,比如是0101,而构造String是遵照的UTF-8到Unicode的变化规则,并且0101这个编码不在UTF-8的编码范围内,所以只能统一把这些无法解码的01序列转化成Unicode的?字符,如果要是哈在GBK编码下不是以0开头 而是以110开头,并且在UTF-8的编码范围之内,那么就可以完成UTF-8像Unicode的转化,只不过byte已经丢失了它原本的符号意义,变成了一堆其他字符。

byte [] bytes = "哈哈".getBytes("UTF-8"); //说明bytes遵照UTF-8编码,01序列的排列方式符号UTF-8编码的要求,如果假设有一个utf8Decoder(byte[] bytes)函数对其进行解码的话,能够得到“哈哈”

String s = new String(bytes, "UTF-8");//将遵照UTF-8编码的bytes数组按照UTF-8到Unicode的转化规则,转化为Unicode的char数组,此时s为“哈哈”;


byte[] bytes = "a中文".getBytes("ISO-8859-1");//由Unicode转ISO,对应下图第一步,由于中文二字在ISO中没有对应编码,所以统一转化成?符号

String s = new String(bytes, "ISO-8859-1");//由ISO转Unicode,遵照的规律就是高位的那一字节补0,低位8字节不变,导致变回来以后就成了a??而不是a中文,因为在第一步时,中文二字被黑洞现象吞掉了。

  1. String-ISO-8859-1〉ByteArray:/u0061/u4E2D/u6587(a中文)-〉0x61 0x3F 0x3F  
  2. ByteArray-ISO-8859-1〉String:0x61 0x3F 0x3F-〉/u0061/u003F/u003F(a??)

抱歉!评论已关闭.