1 背景
在工作中会发现一些职场老鸟也不能很好地处理项目中的字符编解码问题,这个看似简单的问题,但是由于理解没有足够精确,在处理的时候含糊不清,埋下一定的隐患。以下列举了根据实际工作中遇见的情况提取并改编简化的案例,如果读者对这些案例中的问题没有清晰肯定的解决方案,那么建议花一些时间了解下本文,本文会结合这些案例来剖析其背后的深层机制。
案例一
String str = "hello你好";
byte [] utf8 = str.getBytes("utf-8");
byte [] gbk = str.getBytes("gbk");
byte [] unicode = str.getBytes("Unicode");
byte [] hash = DigestUtils.sha256(utf8);
System.out.println("utf-8: " + Hex.encodeHexString(utf8));
System.out.println("gbk: " + Hex.encodeHexString(gbk));
System.out.println("unicode: " + Hex.encodeHexString(unicode));
System.out.println("utf-8base64: " + Base64.encodeBase64String(utf8));
System.out.println("hash: " + Base64.encodeBase64String(hash));
准确描述以上每一行代码所表示的含义。
案例二
客户端需要在http get请求中携带加密参数param1。
案例三
某系统返回类似”68656c6c6fe4bda0e5a5bd“的字符串唯一id,现需要在我们自己的系统中计算这个唯一id的摘要(md5,sha256,或者加密),摘要结果需要保持进数据库。
2 名词理解
工欲善其事,必先利其器。我们先理解一下这两对概念:
编码/解码
从计算机工作的原理来理解编解码这个概念的话可能会容易一些。在本文中主要围绕的是字符编码这个话题展开,因此下面的编解码介绍主要以字符为主。计算机内部处理的是二进制信号,但是我们看到的却是现实世界中多种多样的数据类型:文字、图片、视频等。这些人眼看见的信息在计算机内部都会被转换成二进制进行处理、传输或者存储,这个转换过程就是编解码,即将信息从一种形式转换成另一种形式的过程:

序列化/反序列化
我也基于我自己的理解试图对这两个定义做一些解释。在面向对象程序中,一个被定义好的逻辑数据结构在内存里肯定会占用一块内存块用来存储其实例数据,只是这个内存块是被操作系统、编译器等操作,对于应用开发来说,看到的只是其逻辑结构。由于应用会有各种需求,其中之一就是要把某些实例数据从应用A输出,经过文件、网络等介质进行传输,然后又被另一个应用B读取并在内存里还原。要达到这样的目的,我们能够想到的方式有:1.找到这个内存块,把这块1010的二进制数据导出来 2.设计某种数据表示方式,将实例数据按照其本身的逻辑结构进行输出,然后在程序B中创建对应的类的实例,根据其逻辑结构以此填充实例数据。其实方案1基本是不可行的(受限于操作系统、编程语言等),那么按照方案2的流程,就可以用下图表示,序列化和反序列化的定义就呼之欲出了:将某些对象(或者逻辑结构)在内存里的数据用一种数据表示方法进行转换(序列化),或者将一段结构化的数据按照设定的逻辑在内存中重建出其原本在内存里的对象(反序列化)。

以上两对概念看似有些类似,都是数据的相互之间的变换。但是仔细区分,还是有细节上的差异的。以下是我个人的理解:编码是把人类社会中已经有的各类信息(文字、声音、图片、书籍等)按一定的规则在计算机上用二进制表示;序列化/反序列化则是计算机内存中的物理数据结构在不同介质中按照一定逻辑结构相互转换。
3 字符编码
根据第2节中介绍的编码定义的理解,我们在这里仔细展开分析一下它的内部实现。其实字符编码是对自然界信息编解码问题里面比较好处理的一类,全世界的文字、符号都是有限的,试想一下,我们只要建立一个映射表,第一列是计算机内存里的二进制从0开始的编号,第二列依次填入我们有的字符,这样一个简单的字符编码算法就建立起来了,类似下面这样:

这个图是大家都熟悉的ASCII编码映射关系表,按照这样的一一映射来处理字符编码是很简单也是很直观的方式,实际上我们所有的字符编码也是这样来处理的。那么按照这样处理的话我们就有一个关键的问题要解决:
我们需要使用多少位的二进制来编码字符呢?假设我们全世界的字符(算上一些控制字符)加起来一共有n个,我们使用m位的二进制来编码,那么就要求有2^m >= n 才能满足要求。上面的ASCII就是使用一个字节(8位)二进制来编码英文里的常见字符的,这样一共可以编码2^8 = 256个字符,对于英文来说,这样已经足够了,也是足够的简单。在录入的时候我们在文本软件里面输入一个英文字符,就用对应的一个字节二进制码保存;在读取的时候我们每遇到一个字节,就在显示器上显示这个字节代表的英文字符就好了。
只用一个字节来编码的话仅仅是解决了英文字符的问题,。世界上有那么多种语言文字,在不同语言中有时候仅仅用一个字节是不够用的,因此就有我们经常见到的UTF-8、Unicode、GBK等编码规范出来,然而这些编码规范具体如何定义不是本文讨论的重点,我们也不需要弄清楚他们的实现细节,具备上述的基本原理就足够了。但是这里有一个关键问题需要拿出来讨论一下,也是很多人容易弄混淆的地方:
问题一
在编码规范里规定使用一定长度的二进制来编码字符,我们假设其可以编码的空间是2m,而待编码的字符个数是n,在现有的主流编码算法里,2m都是大于n的,这一点我们也能够很轻松地理解到。因为我们要用足够大的编码空间来容纳我们想要表示的字符。例如上图的ASCII编码图,大于127(0x7f)的二进制则没有被使用。基于这个问题,我们来考虑以下代码:
byte [] random = RandomUtils.nextBytes(10); //随机生成一个长度为10的byte数组
String rStr = new String(random,"ascii");
byte [] result = rStr.getBytes("ascii");
System.out.println(Hex.encodeHex(random));
System.out.println(Hex.encodeHex(result));
-----------------------------------------------------------------
系统输出:
b4395d5d3e94aea99671
3f395d5d3e3f3f3f3f71
我们生成了一段随机的字节数组,然后尝试将这段字节数组用ASCII编码集转换成String对象,然后又获取这个String对象使用ASCII编码方式在内存里面的字节,但是对比转换前后的字节,发现发生了变化。发生变化的主要原因就是我们问题一中提到的,我们输入的字节数组是随机产生的,每个字节的取值范围是0x00-0xff,而ASCII编码参与编码的区间在0x00-0x7f,超出0x7f的字节则没法使用对应的ASCII字符映射上,如何处理这些超出的部分就取决于我们所使用String库了,因此所带来的结果是不可预知的。按照上例所示经给这样一来一回的转换,表现出来的结果就是部分字节原本的信息已经丢失了。为什么要将这个看似简单的问题单独放出来讲呢,接下来举实际工作中所发现的实例:
实例一
A同学要将用户的某几个字符类型的字段进行加密后存储,密文在数据库中的字段也是定义成的字符型。于是A同学在网上搜了一些关于加解密如何实现的文章,但是发现加解密、签名验签等操作的API都是类似这样的:
Cipher
public final byte[] doFinal(byte[] bytes)
即输入输出参数都是byte[],于是他头脑里浮现出的第一个想法就是先用类似上例中String的getBytes() API拿到byte[],然后进行加密,最后再用new String(byte[]) API 将加密后的数据输出String,保存到数据库中。于是他很开心地认为API输入输出参数类型都能顺利对上了,愉快地提交了代码。类似的场景还出现在发送HTTP请求中。其实犯这类错误的根本原因是没有仔细理解二进制数据表示与字符编码之间的区别,将这两个问题混为一谈了。
4 Base64编码
Base64编码和上文中所提到字符集编码不是解决同一类问题的(读者可以自己仔细推敲一下这两者的区别),在这里拿出来介绍是因为现在要解决上述实例一的问题。就像实例一中所遇到的问题,既然我们不能用String的相关API来直接使用byte[],那么我们可以换一种方式,将byte[]在内存里的二进制直接转换成类似“1001“这样的字符串,这样的直接映射最直观也最简单。但是这样做带来的问题是什么呢(接下来需要仔细理解所提到的概念,因为实际工作中会发现很多人在这个问题上模糊不清),一个字节在内存里有8个bit,如果直接用二进制字符串输出这个字节的话会有8个”0“或者”1“,比如最后结果是”11011100“,这段字符串在内存里按照现有的字符集编码规则的话最少要占用8个字节,如果是保存在数据库中也是类似。
System.out.println("11011100".getBytes("ascii").length)
-------------------------------
系统输出:8
那么用上述解决方案带来的问题就是数据会带来8倍存储空间占用,有没有更好的一种方案呢,用十六进制字符串输出?一个字节需要两个十六进制字符串来表示,例如“ff”,存储空间只要原来的2倍了。那还有没有更好的办法呢?计算机前辈们肯定会为了追求极致的计算或者存储性能给出最优的解决方案,那就是Base64编码。其实要想到这样的解决方案也不难,因为我们参与编码的字符数越多,单个字符能表示的信息越多,那这种编码效率也就越高。二进制字符串只用了两个字符编码,十六进制用了十六个字符参与编码,那么我们完全可以设计一套用更多的字符来编码的算法。Base64就是在这样的背景下产生的。它就是选用了64个可打印的字符来编码,这样就是2的6次方,即一个Base64字符可以表示6个二进制位,比十六进制表示方式又高效了一些,这样一个字节只要三分之四个字符就可以表示了。
那么为什么是Base64,而不是Base128呢?仔细思考不难发现,ASCII字符集里面只编码了128个字符,除去一些控制字符,可用的可打印的字符小于128个,但是大于64个,所以选64个来编码。
5 SHA256、MD5等摘要算法简介
以上的基础理论介绍完后,我们就可以看看在实际工作中常见的应用了。在接口对接中我们会经常遇到使用SHA256或者MD5进行签名或者数据完整性校验的情况。它们的定义在这里不是讨论的重点,可以点这里去看百科:SHA256、MD5 。其实我们实际所遇到的加解密、签名验签等问题在所有的编程语言所提供的API中都是和byte[]进行处理的。SHA256、MD5就是这类问题的代表。例如一段SHA256摘要结果:“ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb”,有了前文基础知识的铺垫,我们现在应该就能明白它背后的意义:SHA256摘要结果是一段32个字节的byte[],前面那段字符串只是这段字节数组的十六进制表示而已,我们也可以完全可以用二进制或者Base64来输出这段字节数组:
二进制:
101110110100100011101110101011111000010101110111100000001011100101110010010011100111110000010100111110001110111110000110101001110
1001101110111000010001110011010101100110011000111000010111110101100101010111101000110111100101000010010100000011001011111001010 (一共256个字符)
Base64:
ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=
所以我们在看到一段十六进制字符串表示的SHA256摘要结果就不要认为它本身就是一段字符串,只是十六进制字符串是它们的常用表示方式。
以下分享一些很好用的工具类:
apache的commons-codec里面包含了很多编解码相关的工具类:
org.apache.commons.codec.binary.BinaryCodec //二进制格式
org.apache.commons.codec.binary.Hex //十六进制格式
org.apache.commons.codec.binary.Base64 //base64
org.apache.commons.codec.digest.DigestUtils //计算摘要的工具类
6 总结
- 我们所说的字符集编解码指的是计算机处理文字、符号时所使用的一套二进制映射算法,而Base64、十六进制编码说的是二进制数据表示方法,这两者是不一样的范畴,要仔细区别它们的应用领域,避免混淆。
- 我们常见的UUID、MD5、SH256等技术生成的结果实际上是一段固定长度的字节(假设为A),通常会用十六进制的字符串来表示这段字节,这段字符串本身也是由一段字节来编码的(假设为B),不能把A和B等同来对待。
7 案例分析
有了以上知识储备,我们可以来分析前面提到的案例一到案例三了。
针对案例一和案例二
主要是想说明使用不同的字符集编码方式来获取相同字符的二进制表示,得到的结果可能不一样,案例一的输出结果如下:
utf-8: 68656c6c6fe4bda0e5a5bd
gbk: 68656c6c6fc4e3bac3
unicode: feff00680065006c006c006f4f60597d
utf-8base64: aGVsbG/kvaDlpb0=
hash: 2vGBZ8ykA7Myo8GLAJikcfgVKb/v4IrurR+ODWn4mZw=
下面我们来举一个实例来说明问题:
实例二
客户端同学C要发送带由加密参数的HTTP GET请求,服务端S同学需要将请求结果进行加密后返回。于是就会有这样的数据流转:

重点一: 其中流程a和e是互为逆过程,我们很多同学在这里容易犯的错误是没有显式指定字符集,即没有使用str.getBytes(“utf-8”)和new String(byte[],“utf-8”)这样的API在服务器和客户端显式指定使用哪种字符集来编解码,而是str.getBytes()和new String(byte[])这样使用系统默认字符集来编解码,这样带来的结果就变得不可预知,取决于客户端和服务器的默认实现了,通常情况出现各种乱码就是这样出现的。
重点二: c流程要再一次把密文byte[]转换成字符串是因为HTTP GET接受的是字符型参数,且要满足url规范,一般这里我们使用Base64编码,但是Base64编码它使用到了”=“和”/“这两个URL保留字符,如果参数中直接拼接Base64编码结果的话可能会导致服务器参数解析错误。这个时候我们可以用的解决方案可以有1.所有key和value都经过URLencode,把参数按照URL规范进行转义 2.使用上述介绍到的Base64工具类的encodeBase64URLSafe API,来编码URL安全的Base64结果。
重点三: 这个实例主要说明的问题就是在成对出现的API调用过程中,我们一定要注意编解码的匹配问题,不能含糊不清地处理
针对案例三
其实在总结第2条里面就有说明,我们在遇到类似这样十六进制”68656c6c6fe4bda0e5a5bd“或者Base64表示的字符串,把这个当作唯一id来处理的时候我们要仔细辨别,原则上它是一段全局唯一的字节数组,只是使用了十六进制表示方法以字符串的行式表示出来了,因为它是全局唯一的,所以它的十六进制字符串表示也是唯一的。我们用的时候不管使用字节数组还是字符串来标识唯一性在逻辑上都是没有问题的。但是一旦涉及到不同系统之间的交互,就要看其他系统是怎么处理的。所以一定要仔细区分以下代码所示的含义:
String strId = "68656c6c6fe4bda0e5a5bd";
byte [] strByte = strId.getBytes("utf-8");
byte [] byteId = Hex.decodeHex(strId.toCharArray());
byte [] strSha = DigestUtils.sha256(strByte); //一些封装的工具类提供的API是直接接受一个String,要仔细看看其内部实现是如何处理这段字符串的,不能使用一些不可预知的API
byte [] byteSha = DigestUtils.sha256(byteId);
System.out.println("strByte: " + Hex.encodeHex(strByte)); //仔细区分strByte和byteId的区别
System.out.println("byteId: " + Hex.encodeHex(byteId));
System.out.println("strSha: " + Hex.encodeHex(strSha));
System.out.println("byteSha: " + Hex.encodeHex(byteSha));
---------------输出结果-----------------
strByte: 36383635366336633666653462646130653561356264
byteId: 68656c6c6fe4bda0e5a5bd
strSha: f5952b1959c68c69d4f19b715b4aa3c6ff4f32a17d813ea34e814852f0b8f981
byteSha: daf18167cca403b332a3c18b0098a471f81529bfefe08aeead1f8e0d69f8999c
实例三
同学A需要把一段二进制数据保存进文件中,他将这段数据用Base64编码后把生成的一串字符串写进一个文本文件里(类似的场景还有一段二进制数据需要使用HTTP发送)。
其实计算机的文件都是以二进制行式保存在硬盘上的,我们可以直接将一段二进制数据直接往一个打开了的文件OutputStream里写的,这是最直接的方式。同学A把数据用Base64编码一次后再写会平白浪费一部分存储空间和一个来回的转换开销。但是如果是使用HTTP发送的话,就要结合实际情况来分析,如果这段二进制是这个HTTP接口的全部参数数据,而且以后也可以预见的肯定不会有改变的话,我们可以使用HTTP POST请求,设置Content Type为application/octet-stream,直接将这段二进制发送给服务器,这是效率最高的一种方式,并且可以在HTTP接口的URL上附带一些简单参数,最大化满足扩展需求。
但是如果这段二进制数据只是这个接口请求参数的其中一个字段,还有其他比较复杂的Json参数,那么则需要将这段二进制使用Base64编码成字符串后和其他参数一起构造Json对象,这样会带来一些额外的带宽开销。如果二进制数据比较大的话可以考虑使用Protobuffer这种面向字节的序列化方式来节省网络传输带宽。
总之如果对技术细节推敲的越深入,能够采取的方案越是高效合理,不至于用一些看起来很别扭的方式来实现。
本文通过案例分析,深入浅出地讲解了计算机编解码的基础知识,包括字符编码、Base64编码、SHA256和MD5摘要算法。强调了编码、解码与序列化、反序列化的区别,并指出在处理字节和字符串时可能出现的混淆,如不正确使用字符编码可能导致的数据丢失。文章适合对编解码有困惑的开发者阅读。

3665

被折叠的 条评论
为什么被折叠?



