你是否也在寻找二进制和字符串的高效转换算法?

时间:2024-09-29 17:22:59

做底层框架或技术产品的研发同学在代码调优过程中不可避免的会遇到序列化/反序列化的场景,除了面向前端的场景一般情况下我们会选取一些二进制序列化框架比如hessian2Protocol Buffers、Thrift、Avro、Kryo等,它们体积更小、精度更高也更安全,但当需要存储时需要将其转为字符串,业界比较普遍使用的方案是Base64 https://en.wikipedia.org/wiki/Base64,它是一种早期且至今仍流行的编码,于 1987 年 首次作为RFC 989的一部分指定,对于Java程序员而言使用它非常的便捷,因为它已经被集成进入了jdk中。

import java.util.Base64;

public class Base64Example {

  public static void main(String[] args) {
      // 原始二进制数据
      String originalInput = "Hello, World!";
      
      // 编码
      String encodedString = Base64.getEncoder().encodeToString(originalInput.getBytes());
      System.out.println("Encoded: " + encodedString);
      
      // 解码
      byte[] decodedBytes = Base64.getDecoder().decode(encodedString);
      String decodedString = new String(decodedBytes);
      System.out.println("Decoded: " + decodedString);
  }
  
}

文本友好性、支持广泛、无特殊字符、易于实现等等特点使其成为了二进制转文本最流行的选择,但它也有着致命的缺陷——相对于原始二进制数据的大小会产生 33–37% 的开销(其中 33% 来自编码本身;最多可增加 4% 来自插入的换行符)。

当数据量不多的时候这些开销看起来还容易承受,但随着数据量的不断攀升找到其它更高效的算法就成为了一种必然的选择,参考https://en.wikipedia.org/wiki/Binary-to-text_encoding

下表比较了最常用的二进制到文本编码形式。列出的效率是输入位数与编码输出位数之间的比率(如Base64每三个字节的数据被编码成四个字符效率为3/4=75%)

编码 数据类型 效率 备注
Ascii85 随意的 80% 这种编码有几种变体,Base85,btoa等。
Base32 随意的 62.5%
Base36 整数 ~64% 使用阿拉伯数字0–9 和拉丁字母A–Z(ISO 基本拉丁字母)。通常由TinyURL或 SnipURL/Snipr 等URL 重定向系统用作紧凑的字母数字标识符。
Base45 随意的 约 67% (QR码97% ) 在 IETF 规范 RFC 9285 中定义,用于在QR 码中紧凑地包含二进制数据。
Base56 整数 Base58 编码的一种变体,进一步删除了“1”和小写的“o”字符,以最大程度地降低欺诈和人为错误的风险。
Base58 整数 ~73% 与 Base64 类似,但经过修改,避免使用非字母数字字符(+ 和 /)以及打印时可能看起来模棱两可的字母(0 – 零,I – 大写 i,O – 大写 o 和 l – 小写 L)。Base58 用于表示比特币地址。[需要引用]一些消息传递和社交媒体系统会在非字母数字字符串上换行。通过不使用URI 保留字符(例如 +)可以避免这种情况。对于SegWit,它已被 Bech32 取代,见下文。
Base62 随意的 ~74% 与 Base64 类似,但仅包含字母数字字符。
Base64 随意的 75% 一种早期且至今仍流行的编码,于 1987 年 首次作为RFC 989的一部分指定
Base85 随意的 80% Ascii85的修订版本。
Base91 随意的 81% 恒定宽度变体
basE91 随意的 81% 可变宽度版本
Base94 随意的 82%
Base122 随意的 87.5%
BaseXML 随意的 83.5%
RFC 1751 (S/KEY) 随意的 33% “人类可读的128 位密钥约定”。与十进制或其他二进制到文本的编码系统相比,一系列小的英文单词更易于人类阅读、记忆和输入。[ 12 ]每个 64 位数字都映射到六个短词,每个短词由 1 到 4 个字符组成,这些短词来自公共的 2048 字词典。
S-record (Motorola hex) 随意的 49.6% 通常用于编程EPROM、NOR 闪存芯片。49.6% 假设每个记录有 255 个二进制字节。
Xxencoding 随意的 ~75% (与 Uuencoding 类似) 建议(并偶尔使用)作为 Uuencoding 的替代品,以避免 ASCII 和 EBCDIC 系统之间的字符集转换问题,这些问题可能会损坏 Uuencoded 数据
z85 (ZeroMQ spec:32/Z85) 二进制和 ASCII 80%(类似于 Ascii85/Base85) 指定类似于Ascii85的 ASCII 子集,省略一些可能导致程序错误的字符(` \ " ’ _ , ;)。格式符合ZeroMQ spec:32/Z85。
BinHex 随意的 75% 经典版 MacOS
Hexadecimal (Base16) 随意的 50% 有大写和小写两种形式
Decimal 整数 ~42% 通常是人类输入/输出的默认表示。
TxMS 十六进制 ~32% 用于使用UTF-16BE通过短信传输区块链交易。
Quoted-printable 文本 ~33–100% [ d ] 保留换行符;在 76 个字符处剪断行
MIME 随意的 请参阅Quoted-printable和Base64 用于电子邮件格式的编码容器
Tektronix hex 随意的 通常用于编程EPROM、NOR闪存芯片。
Percent-encoding 文本 ( URI ),任意 ( RFC1738 ) 约 40% [ b ](33–70% [ c ])
Uuencoding 随意的 ~60% (最高可达 70% ) 1980 年为Unix-to-Unix复制开发的早期编码。大部分已被 MIME 和yEnc取代
Intel HEX 随意的 ≲50% 通常用于编程EPROM、NOR 闪存芯片
Bech32 随意的 62.5% + 至少 8 个字符(标签、分隔符、6 个字符的ECC) 规范。用于比特币和闪电网络。数据部分采用类似 Base32 的编码方式,可以使用末尾的 6 个字符BCH 代码检查和更正最多 6 个输入错误的字符,这还可以检查/更正人类可读部分。Bech32m 变体有一个微妙的变化,使其更能适应长度的变化。

可以看出Base122 http://blog.kevinalbs.com/base122 是其中效率最高的一种算法,它诞生的目的是用在网页上替换Base64,作者给出了JS的实现https://github.com/kevinAlbs/Base122 ,但不幸的是没有人将其移植到Java中,因此Java的同学没办法直接使用此算法,同时很多迹象表明其与Base64相比稳定性差很多,所以如果想替换Base64同时又对稳定性有一定那么我们推荐你使用Base85算法,这里给出一版Java实现(仅供参考

public class Base85 {

  private final static char[] ALPHA = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#".toCharArray();

  private final static int[] DECODE_CHAR_MAP = {
      68, 0, 84, 83, 82, 72, 0, 75, 76, 70, 65, 0, 63, 62,
      69, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 64, 0, 73, 66, 74, 71,
      81, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
      48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
      77, 0, 78, 67, 0, 0, 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, 79, 0, 80};

  private final static int CHAR_INDEX_OFFSET = 33;

  /**
   * Encode the Byte array into Base85 format.
   *
   * @param input byte[] Array of byte to encode.
   * @return String The encoded String
   */
  public static String encode(byte[] input) throws RuntimeException {

    // check input len > 0 or null//
    if (input == null || input.length == 0) {
      throw new IllegalArgumentException("Input is wrong");
    }

    int length = input.length;
    int index = 0;
    byte[] buff = new byte[4];

    // Use mutable StringBuilder for fast string append //
    StringBuilder sb = new StringBuilder((input.length * 5 / 4) + 1);

    while (length >= 4) {

      // copy input to buff //
      buff[3] = input[index++];
      buff[2] = input[index++];
      buff[1] = input[index++];
      buff[0] = input[index++];

      // Append result string to StringBuilder
      sb.append(encodeQuarter(buff));

      length -= 4;
    }

    // Padding zone //
    if (length > 0) {

      buff = new byte[length];

      for (int i = length - 1; i >= 0; i--) {
        buff[i] = input[index++];
      }

      // Append result string to StringBuilder
      sb.append(encodePadding(buff));
    }

    // Return whole string //
    return sb.toString();
  }

  /**
   * Decodes a Base85 encoded string.
   *
   * @param input byte[] String The encoded Base85 String.
   * @return byte[] The decoded array of bytes.
   */
  public static byte[] decode(String input) throws RuntimeException {

    // check input len > 0 or null//
    if (input == null || input.isEmpty()) {
      throw new IllegalArgumentException("Input is wrong");
    }

    int length = input.length();
    int index = 0;

    char[] buff = new char[5];

    ByteBuffer byteBuffer = ByteBuffer.allocate((length * 4 / 5));

    while (length >= 5) {

      buff[0] = input.charAt(index++);
      buff[1] = input.charAt(index++);
      buff[2] = input.charAt(index++);
      buff[3] = input.charAt(index++);
      buff[4] = input.charAt(index++);

      byteBuffer.put(decodeQuarter(buff));

      length -= 5;
    }

    // If last length > 0 Then need padding //
    if (length > 0) {

      // create padding buffer //
      char[] padding = new char[length];

      // copy last input value to padding buffer //
      for (int i = 0; i < length; i++) {
        padding[i] = input.charAt(index++);
      }

      // decode padding //
      byteBuffer.put(decodePadding(padding));
    }

    byteBuffer.flip();

    if (byteBuffer.limit() == 0) {
      throw new RuntimeException("Output is empty!");
    }

    return Arrays.copyOf(byteBuffer.array(), byteBuffer.limit());
  }

  private static char[] encodeQuarter(byte[] data) {

    long value = (data[0] & 0x00000000000000FFL) |
        ((data[1] & 0x00000000000000FFL) << 8) |
        ((data[2] & 0x00000000000000FFL) << 16) |
        ((data[3] & 0x00000000000000FFL) << 24);

    char[] out = new char[5];

    out[0] = ALPHA[(int) ((value / 0x31C84B1L) % 85)];
    out[1] = ALPHA[(int) ((value / 0x95EEDL) % 85)];
    out[2] = ALPHA[(int) ((value / 0x1C39L) % 85)];
    out[3] = ALPHA[(int) ((value / 0x55L) % 85)];
    out[4] = ALPHA[(int) ((value) % 85)];

    return out;
  }

  /**
   * Encode padding scheme
   *
   * @param data byte[] Array of length = 4 of data
   * @return char[] Encoded padding
   */
  private static char[] encodePadding(byte[] data) {

    long value = 0;
    int length = (data.length * 5 / 4) + 1;
    char[] out = new char[length];

    switch (data.length) {
      case 3:
        value |= (data[2] & 0x00000000000000FFL) << 16;
      case 2:
        value |= (data[1] & 0x00000000000000FFL) << 8;
    }

    value |= (data[0] & 0x00000000000000FFL);

    switch (data.length) {
      case 3:
        out[3] = ALPHA[(int) ((value / 0x95EEDL) % 85)];
      case 2:
        out[2] = ALPHA[(int) ((value / 0x1C39L) % 85)];
    }

    out[1] = ALPHA[(int) ((value / 0x55L) % 85)];
    out[0] = ALPHA[(int) ((value) % 85)];

    return out;
  }

  private static byte[] decodeQuarter(char[] data) {

    long value = 0;

    value += DECODE_CHAR_MAP[data[0] - CHAR_INDEX_OFFSET] * 0x31C84B1L;
    value += DECODE_CHAR_MAP[data[1] - CHAR_INDEX_OFFSET] * 0x95EEDL;
    value += DECODE_CHAR_MAP[data[2] - CHAR_INDEX_OFFSET] * 0x1C39L;
    value += DECODE_CHAR_MAP[data[3] - CHAR_INDEX_OFFSET] * 0x55L;
    value += DECODE_CHAR_MAP[data[4] - CHAR_INDEX_OFFSET];

    return new byte[]{
        (byte) (value >>> 24),
        (byte) (value >>> 16),
        (byte) (value >>> 8),
        (byte) (value)
    };

  }

  private static byte[] decodePadding(char[] data) {

    long value = 0;
    int length = data.length * 4 / 5;

    switch (data.length) {
      case 4:
        value += DECODE_CHAR_MAP[data[3] - CHAR_INDEX_OFFSET] * 0x95EEDL;
      case 3:
        value += DECODE_CHAR_MAP[data[2] - CHAR_INDEX_OFFSET] * 0x1C39L;
      case 2:
        value += DECODE_CHAR_MAP[data[1] - CHAR_INDEX_OFFSET] * 0x55L;
    }

    value += DECODE_CHAR_MAP[data[0] - CHAR_INDEX_OFFSET];

    byte[] buff = new byte[length];

    for (int i = length - 1; i >= 0; i--) {
      buff[length - i - 1] = (byte) (value >>> 8 * i);
    }

    return buff;
  }

}

测试用例

public class Base85Test {

  @Test
  public void test() {
    for (int i = 0; i < 1000; i++) {
      byte[] rnd = seedRandomByte(i + 4);
      String encoded = Base85.encode(rnd);
      byte[] decoded = Base85.decode(encoded);
      ArrayAsserts.assertArrayEquals(rnd, decoded);
    }
  }

  @Test
  public void testEncode() {
    // Without padding, normal hello world :) //
    byte[] in_test = new byte[]{(byte) 0x86, (byte) 0x4F, (byte) 0xD2, (byte) 0x6F, (byte) 0xB5,
        (byte) 0x59, (byte) 0xF7, (byte) 0x5B};
    Assert.assertE