Base128 基于Base64的变种编码

来源:互联网 发布:windows xp图片 编辑:程序博客网 时间:2024/05/29 04:32

Base128 基于Base64的变种编码

前言

毕设中的需求比较特殊,需要大量的用到长连接来进行数据传输,所以使用了Stomp来作为应用层协议。由于spring对其支持良好,服务端直接使用spring相关api完成编码。在spring中,其底层实现为WebSocket,可以简单理解为,在WebSocket的基础之上,对每一条消息封装一个包头(stomp协议)再进行传输,从而使消息的分发更加灵活,如订阅话题等等。这里不过多展开Stomp,但有一点是需要注意的是,Stomp是一个基于文本的协议,对二进制的传输不是特别友好。这主要是因为在Stomp中,使用ascii码0来代表一条消息的结束。这也就意味着,如果消息中包含ascii码0,这条消息是无法完成的进行解析的。然而比较尴尬的一点是,毕设里使用了一种二进制的对象序列化库(ProtocolBuffer),使得在进行消息传输之前,必须进行编码操作,所以今天来聊一聊编码优化的问题。

关于Base64

base64大家或多或少有些了解,这里希望详细分析下base64原理以及jdk1.8中对其提供的实现方式,同时解释下为什么这次没有使用Base64,而是写了一个听起来就很山寨的Base128…
base64经常用于解决网络数据传输的编码问题,要是有什么乱码的,发不过去数据的,拿base64一编码就老实了,听起来特别的高大上,其实原理简单到令人发指,了解过之后会有一种我上我也行的冲动。他主要是解决字符的转换问题,要知道,我们在屏幕上看到的每一个汉字、字母,其实都对应着一串数字,base64的工作就是把一串数字转变成另外一串数字,避开那些敏感的数字就可以了。
直接举个例子:比如我有三个数字

6 6 6

对应的二进制表示是

 00000110 | 00000110 | 00000110  //这里用竖线稍微隔了一下

也就是说 每个数字对应8个二进制位
然后,我们稍稍向右挪动下这几个竖线,让他们变成6个一组,于是就成了这样

000001 | 100000 | 011000 | 000110  //8个一组变6个一组所以多加了条竖线

把这四组直接当做4个数字,也就是每组数字高位补两个0就变成了这样

00000001 | 00100000 | 00011000 | 00000110  

再转回十进制看一下就是

1 32 24 6

这样base64的工作就完成大半了,有没有发现,经过编码后的每一个数字,都不可能超过64,因为他的高两位永远是0,所以,在最后,我们将每一个数字按照编码表对应成64种可打印字符,整个编码的核心工作就完成了。当然我举的例子比较理想,因为三个数字经过编码正好可以变成四个,如果不巧数字的个数不能被3整除,可能还要做一些补齐操作,但这不重要,如果你很好奇,随便百度下就好了。
由于经过base64的编码之后,所有的二进制数据不管是几都落到可打印字符区间了,这样就能直接在网络中放心传输了。只是数据的长度变长了,这个没办法,原本一个byte可以表示-128~127,现在每个byte的高两位都不能表示数据,相当于拿空间换值域,为了正常的传输不得不作出的妥协。其实这也是我想对其改进的原因,对我来说,可能这对空间浪费的有点多了,同时更长的编码长度意味着更多的赋值操作,在java上数组访问和赋值是非常慢的,我们应该尽量减少这种操作的发生,所以才有了Base128的想法。在讲Base64之前,我们先来分析下jdk中的Base64是如何实现的

java中的Base64

在解析源码之前,我想先说明下java中位操作的基本知识,不了解的话就很难看懂下面的代码。其实要了解的东西也不多,我们只要明确几个细节就好了

  • byte->int

    byte b = -128;
    int a = (int)b; //求a的二进制表示

    我们知道b的二进制表示为0x11111111 其符号位为1,将其强制转换为32位的int时,系统会将高位全部补1 转换后的值为
    0x11111111111111111111111111111111
    并不是我们想要的
    0x00000000000000000000000011111111
    所以如果想要达到我们想要的结果 需要将上述代码改为

byte b = -128;int a = (int)(b & 0xff);    
  • 位或
    如果我们希望将两个byte合并为一个int,可以通过位移+位或的操作来完成,还是上面的例子,如果我们有两个byte b1=b2=-128,要将两个byte合并在一起,首先要将两个byte都变成这种形式
    b1: 0x00000000000000000000000011111111
    b2: 0x00000000000000000000000011111111
    接着我们对b2做左移操作 两个数将变成这样
    b1: 0x00000000000000000000000011111111
    b2: 0x00000000000000001111111100000000
    然后我们对两个数做位或操作,结果就变成了
    b0: 0x00000000000000001111111111111111
    可以理解为,如果我有一个数据要合成到另外一个数据上b2->b1,那么我要保证在b1中存在数据的区域对应着b2中全部为0,在b2中存在数据的区域对应着b1中全部为0,这样通过或操作即可完成两个数据的合并。因为对于b1来说,有人要合并到我身上来,只要我保证那片区域都是0就可以了,0或0是0,0或1是1,不管你是啥,总能通过这种方式或上来。对于b2来说,我要合并到别人身上,但我不能影响他其他位置上的原始值,那么我就把其他地方置0,这样经过或操作,别人是啥还是啥,总是不影响其他位置的原始值。

  • 位与
    继续上面的例子,如果想要把刚刚的b2从b0上拆下来,我们需要用到位移和位与操作
    b0: 0x00000000000000001111111111111111
    由于b2位于8~15位,所以我们先对b0做右移操作
    b0: 0x00000000000000000000000011111111
    这样后面的8位就被移没了,然后我们就可以通过位与一个bx的方式
    b0: 0x00000000000000000000000011111111
    bx: 0x00000000000000000000000011111111
    将b0中的值落到bx中去,这个例子有点不好,因为b0跟bx是一样的,实际上,不管b0的低八位是啥,总能通过位与的方式将其值落到bx中,比如
    b0: 0x00000000000000001111111110101011
    bx: 0x00000000000000000000000011111111
    或者
    b0: 0x00001010000110010100110001011011
    bx: 0x00000000000000000000000011111111
    或者什么乱七八糟的东西,都能通过位与操作将b0的低八位取下来,读者可结合位或自行分析。

好了这下我们可以顺畅无阻的阅读jdk源码了这里直接分析核心代码

    int bits = (src[sp0++] & 0xff) << 16 |               (src[sp0++] & 0xff) <<  8 |               (src[sp0++] & 0xff);    dst[dp0++] = (byte)base64[(bits >>> 18) & 0x3f];    dst[dp0++] = (byte)base64[(bits >>> 12) & 0x3f];    dst[dp0++] = (byte)base64[(bits >>> 6)  & 0x3f];    dst[dp0++] = (byte)base64[bits & 0x3f];

上面这段代码是一段循环中的代码,在上面的代码中,你会发现有src和dst两个数组,分别代表了编码前的数组和编码后的数组,仔细观察,你会发现代码其实是分两段的,第一段是:

int bits =  (src[sp0++] & 0xff) << 16 |            (src[sp0++] & 0xff) <<  8 |            (src[sp0++] & 0xff);

是不是出现了前文提到的合成几个byte的方式,做位移,然后做位或。所以,这一步是将三个byte合成为一个int。然后第二部就是

    dst[dp0++] = (byte)base64[(bits >>> 18) & 0x3f];    dst[dp0++] = (byte)base64[(bits >>> 12) & 0x3f];    dst[dp0++] = (byte)base64[(bits >>> 6)  & 0x3f];    dst[dp0++] = (byte)base64[bits & 0x3f];

是不是出现了前文提到的拆分byte 的方式,做位移,然后做位与,这里他位与了0x3f,也就是00111111 ,这意味着他是6位为一组来拆分的,第一行挪18位是为了获取int中最左边的那6位,紧跟其后,其他的位移意图很明显了。将其拆分下来后,这个值作为base64数组的一个索引,完成一次转换操作,这个base64数组就是我之前提到的编码表,编码表不粘了,大家自己翻一翻吧。

简单概括,jdk的实现方式为

  • 转int -> 做位移 ->
    转int -> 做位移 -> 拼上去->
    转int -> 做位移 -> 拼上去

    之后再

  • 做位移-> 拆下来->做转换->
    做位移-> 拆下来-> 做转换->
    做位移-> 拆下来-> 做转换->
    拆下来-> 做转换

解码方式大同小异,就不再展开了,大家仔细数一下,为了完成对3个byte的编码,jdk一共做了18步处理,平均每个byte需要6步,为什么要算的这么细,因为我想说的是,Base128会做的更快一点。

关于Base128

由于stomp只对0敏感,对于负数,也就是符号位为1的数是允许传输的,所以我们直接拿base64抛掉高两位的话是有点浪费了,处于种种原因,最终我决定对于一个byte,8位中保留7位,保证最后一位是1,这样每个byte一定不是0,所以这应该也算是Base64的一个变种吧,只不过每个byte利用了7位,可表示128种数字,所以起名为Base128。下面我想分析下他的实现方式。

如果直接采用base64的方式进行编解码的处理,7个作为一组,先把他们合并起来,然后再七个七个的拆下来,之后末尾置1,经过这番操作,你会发现对于每个byte的操作步数会多了一些,这是因为很大一部分操作都浪费在合并与拆解上了,毕竟七个一组,不像三个,合并会额外多出来很多或操作,拆解也会带来额外的与操作,我是指的平均情况,大家可以亲自试下是不是这样的。

所以对于Base128,这里提出一种更为简单的实现方式,既然我们要保证末位是1,那么,我们直接把末位存储到别的地方就好了,这样恢复的时候从把这一位从别处提取出来,再合回去即可。
直接上一小段代码

    int s = src[m + k];    dest[i + k] = (byte) (s | 0x01);    k++;    signal = signal | ((s & 0x01) << k);

大家可以看到,上图的s就是一个待编码数据,第一步将s末位置1存入dest,之后将s的末位位移下存入signal中,这个single被放在了dest数组靠前的位置。稍后我会放出全部代码,大家可以仔细阅读下。

所以,对于每个byte的编码操作,其实只有4步,分别是

  • 位或
  • 位与、位移、位或
    对于整个编码完成后的dest数组,可以大致上分为两部分
| 存放末位 | 存放末位为1的原始数据 |

由于我们是7个编码成8个,所以我们可以通过数组总长度计算出存放了多少位末位,存放了多少位数据,这里为了编码上的方便,将末位与原始数据分开存放了,所以也就不需要任何的补全措施了,直接拿数组长度计算即可。

这样一来,经过一番努力,我们的编码速度比base64快大概百分之二十到三十,文件体积比base64减小了百分之十五,这也算是我在base64上压榨出的最后一点性能吧…

好了,源码在这,如果你的情况与我类似,也可以考虑试下Base128,主要因为他快,而且小…

package com.congxiaoyao.location;/** * Created by congxiaoyao on 2017/3/7. */public class Base128 {    public static byte[] encode(byte[] src) {        if (src == null) return null;        int len = src.length;        byte[] dest = new byte[getEncodedLength(len)];        if (dest.length == 0) return dest;        int e = getExtraLength(len), i = e, j = 0, m = 0;        len = dest.length - 7;        for (; i < len; i += 7, m += 7, j++) {            int signal = 1, k = 0;            while (k < 7) {                int s = src[m + k];                dest[i + k] = (byte) (s | 0x01);                k++;                signal = signal | ((s & 0x01) << k);            }            dest[j] = (byte) signal;        }        int signal = 1, k = 0;        len += 7;        for (; i < len; i++, m++) {            int s = src[m];            dest[i] = (byte) (s | 0x01);            k++;            signal = signal | ((s & 0x01) << k);        }        dest[j] = (byte) signal;        return dest;    }    public static byte[] decode(byte[] src) {        if(src == null) return null;        byte[] dest = new byte[getDecodedLength(src.length)];        if (dest.length == 0) return dest;        int len = dest.length;        int i = 0, j = 0, e = getExtraLength(len);        for (; i < len - 7; i += 7, j++) {            int signal = src[j], k = 0;            while (k < 7) {                int index = i + k;                dest[index] = (byte) (src[e + index]                        & ((signal >> ++k) | 0xFE));            }        }        int signal = src[j], m = 1;        for (; i < len; i++, m++) {            dest[i] = (byte) (src[e + i] & ((signal >> m) | 0xFE));        }        return dest;    }    /**     * @param srcLen 编码前的长度     * @return 编码后的长度     */    private static int getEncodedLength(int srcLen) {        float f = srcLen * 8 / 7.0f;        return (int) (f == (int) f ? f : f + 1);    }    /**     * @param srcLen 解码前的长度     * @return 解码后的长度     */    private static int getDecodedLength(int srcLen) {        return srcLen * 7 / 8;    }    /**     *     * @param srcLen byte[]在编码之前的长度     * @return     */    public static int getExtraLength(int srcLen) {        float f = srcLen / 7.0f;        return (int) (f == (int) f ? f : f + 1);    }}
0 0