你到底行不行,字符集踩坑小记

2017-11-23  本文已影响0人  李眼镜

一、踩坑实录

关于字符串编码,遇到过一个比较有意思的问题。

1.1 case背景

服务 A 请求服务 B 时,参数列表带了一个字符串s="中国银行"。但是请求失败了,服务 B 给服务 A 返回了一个错误信息为 errmsg="s包含特殊字符" 的异常。

已知信息简述如下:

  1. 服务 A 从上游接收 UTF-8 编码的字符串数据。
  2. 服务 A 和 服务 B 使用 http 通信,A 对字符串做了URLEncoder(GB18030),B 对接收到的字符串做了URLDecoder(GBK)。
  3. 在此之前,也有过从 A 到 B 参数为 s="中国银行" 的请求,但没报过这种异常。

1.2 问题定位

首先怀疑可能是 GBK 和 GB18030 两种编码格式不兼容(尽管有些资料会告诉你它们是兼容的),在日志上把数据拷贝下来,做了如下测试:

  private static void demo1() throws UnsupportedEncodingException {
    String s = "中国银行";

    System.out.println(s + " GBK-GBK编解码还原结果        :" + testReduction(s, "GBK", "GBK"));
    System.out.println(s + " GB18030-GBK编解码还原结果    :" + testReduction(s, "GB18030", "GBK"));
    System.out.println(s + " GB18030-GB18030编解码还原结果:" + testReduction(s, "GB18030", "GB18030"));
  }

  // 获取字符串 URLEncoder=encode 编码 再 URLDecoder=decode 解码的还原结果
  private static String testReduction(String str, String encode, String decode)
      throws UnsupportedEncodingException {
    String encodeStr = URLEncoder.encode(str, encode);
    return URLDecoder.decode(encodeStr, decode);
  }

运行结果:

中国银行 GBK-GBK编解码还原结果        :中国银?
中国银行 GB18030-GBK编解码还原结果    :中国银�0�0
中国银行 GB18030-GB18030编解码还原结果:中国银行

即,GB18030 对 这个字 URLencode 后再 URLdecode 是没问题的,而 GBK 编码后无法再解回来,基本可以判定:GB18030 支持 这个字,GBK 不支持。

1.3 疑问

但是有点不太对劲啊,“行”这个汉字使用频率还是比较高的,属于一级汉字的范畴,GBK 不可能不支持,去 GBK 码表搜 也是能搜到的。那为什么会出现这种情况呢?难道 GBK 码表里的 和本例中的 不是同一个字?

private static void demo2()  {
    String s1 = "行";// 从日志上拷贝的
    String s2 = "行";// 从GBK码表拷贝的
    System.out.println(s1.equals(s2));
}

结果为:false,也就是说,GBK 码表里支持的 字,和本例中上游传过来的 字(日志中拷贝的)确实是不相同的。

知识点来了,java 中的 String 类是按照 unicode 进行编码的,如果两个字符不相同,说明它们的 unicode 码不同。

为了确认这一点,借助工具分别查了一下这两个字的 unicode 码,以及各个字符集对这两个字的支持情况,如下图:

  1. 日志中拷贝的,即上游传过来的
    日志中拷贝的,即上游传过来的`行`
  1. GBK字符集中拷贝的
    GBK字符集中拷贝的“行”

如上,与猜想的一致,Unicode 和 GB18030 都支持 ,但编码不同;而GBK 仅支持 。同步上游将这个字修正为 GBK 支持的“行”字后对外请求可以正常提交,不再赘述。

澄清:GB18030 和 GBK 兼容指的是 GB18030 向下兼容 GBK,不是互相兼容。

那么,问题又来了=>

二、为什么一个字会被编两个 unicode 码呢?

2.1 编码方案的“本地化”和“国际化”

我们都知道,最早 ASCII 是一种单字节编码方案,共支持 128 个字符。

但计算机到了中国后,ASCII 不够用了,就有了 GB 系列的 GB2312、GBK、GB18030 等双字节/多字节编码标准。

同样的,在其他国家/地区,计算机字符编码也遭遇了“水土不服”的情况,于是各个国家/地区为了让自己的文字能够被支持,都制定了各自的编码标准。这样一来,各地区之间的编码标准互不兼容,多语言环境下的文字处理就变的很头疼。

所以,后来就有了 Unicode 编码标准,目的就是制定国际规范,将各国家各地区的字符收录到一起,统一编码并推动各地区实施该规范,以解决全球范围内地区性的编码方案互不兼容带来的一系列问题。

2.2 Unicode 和 CJK

Unicode 的字符来源于各国各地区已有的编码体系,叫做“字源”,比如:中国大陆的G源、中国台湾的T源、日本的J源、韩国的K源等。

为了尽可能使 Unicode 收录的字符完整精简且利于推广,Unicode 从不同的资源中收录字符时遵循两个原则:

  1. 对字不对形原则,即一个字符只收录一次,同字不同形的字符不予多次录入。目的是尽可能的精简收录到 Unicode 内的字符数量。
  2. 字源分离原则,若同一字源收录了同一字符的多个字形,则 unicode 需要与字源规范一致,屏蔽上面的“对字不对形”原则,转而同时收录这同一个字符的多个字形。目的是便于 Unicode 推广。

到这里就要把“汉字”单拎出来提一提,中文、日文、韩文里,都有汉字。(越南文和新加坡文里也有汉字)

为了便于汉字的统一处理,ISO 10646 和 Unicode 成立了“中日韩联合研究小组”,基于各国的汉字编码,独自订定规范、制作 ISO 10646 和 Unicode 的统一汉字编码,也就是“中日韩统一表意文字(CJK Unified Ideographs)”,把来自中文、日文、韩文中,起源相同、本义相同、形状一样或稍异的表意文字,赋予其在 ISO 10646 及Unicode 标准中有相同编码。

在 Unicode 码表中,可以看到有一个块区叫做 “CJK Unified Ideographs” ,指的就是上面的统一汉字。除了这个块区外,还有其他的带 CJK 前缀的块区,也都属于 CJK 范畴。

关于“字源分离原则”,举个例子:有一些 CJK 汉字各地字型多有微妙的差异,如“户”字的第一笔,台湾作撇“戶”、中国香港及中国大陆作点“户”、日本作横“戸”,这种程度的差异,理想上是整并为一个字为佳。然而,从之前各种受挫之文字整并计划的经验得知,整合字集与现行通用字集无法一一对应,是推行整合字集的最大阻碍。所以折中一点的办法就是把这几个字都收了。

2.3 延伸

延伸一下,关于前面的问题:为什么一个字被编了两个码,到这里算是有了答案:某个字源同时收录了同一字符的两个字形。

在 Unicode 码表中,其实是可以查到每一个字符的字源的。
比如:

编码为 FA08 的 行

编码为FA08的“行”字字源只有一个 编码为FA08的“行”字字源只有一个,K0指的就是韩国K源(字符集:KS C 5601-1987),参考: K源

编码为 884C 的 行

编码884C的“行”字字源有5个

编码884C的“行”字字源有5个

如上分析可知,韩国字源的 KS C 5601-1987 字符集同时收录了 ,到 Unicode 也就有了两个编码的 字,一个编码 884C ,另一个编码 FA08。

汉字字源说明参见:Unicode Han Database (Unihan)

三、思考和改进

踩了这么个坑,怎么填呢?可以从两个角度去考虑:

  1. 技术层面,可否提供识别汉字,或者中文汉字的检查能力?
  2. 业务处理流程中,哪些场景会需要做此类检查?

3.1 技术能力

技术角度,考虑3个问题:

  1. 能不能识别一个字符是否为汉字?=>能
  2. 能不能识别一个字符是否为中文汉字?=>不能
  3. 能不能判断某个字符集是否支持指定汉字字符?=>能

ok,简单写个util,代码如下:

package com.ann.javas.javacores.strings;

import java.io.UnsupportedEncodingException;

public class CharacterUtil {

  private final static String GBK = "GBK";
  private final static String GB2312 = "GB2312";
  private final static String GB18030 = "GB18030";

  // 判断字符串是否全部为CJK统一表意文字(仅CJK统一表意文字,不包括扩充集)
  public static boolean isAllCJKLetter(String strName) {
    char[] ch = strName.toCharArray();
    for (char c : ch) {
      if (!isCJKLetter(c)) {
        return false;
      }
    }
    return true;
  }
  
  private static boolean isCJKLetter(char c) {
    Character.UnicodeBlock ub = Character.UnicodeBlock.of(c);
    return Character.isDefined(c) && ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS;
  }

  // 判断字符串是否全部为CJK系列字符(CJK统一表意文字及扩充字符)
  public static boolean isAllCJK(String strName) {
    char[] ch = strName.toCharArray();
    for (char c : ch) {
      if (!isCJK(c)) {
        return false;
      }
    }
    return true;
  }

  private static boolean isCJK(char c) {
    Character.UnicodeScript uc = Character.UnicodeScript.of(c);
    return Character.isDefined(c) && uc == Character.UnicodeScript.HAN;
  }


  // 判断字符串是否被指定字符集支持
  public static boolean isGBK(String str) {
    return isSupportedCharset(str, GBK);
  }

  public static boolean isGB2312(String str) {
    return isSupportedCharset(str, GB2312);
  }

  public static boolean isGB18030(String str) {
    return isSupportedCharset(str, GB18030);
  }

  private static boolean isSupportedCharset(String str, String charsetName) {
    try {
      return str.equals(new String(str.getBytes(charsetName), charsetName));
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
      return false;
    }
  }

}

3.2 业务处理

业务流程中,需要做汉字字符检查或字符集过滤的场景并不多,举两个例子如下:

下游服务对字符编码有严格要求
有一些系统由于历史原因,仅支持 GB2312 字符,接口文档上就会明确要求编码格式为 GB2312 。在此场景下,上游就需要加字符集过滤策略。

    public boolean scene1(String xmlRequest){
      return CharacterUtil.isGB2312(xmlRequest);
    }

特殊业务需要检查中文字符
用户实名时,为了尽可能的把错误请求拦截在系统外,需要检查输入的用户姓名是否为 CJK 汉字字符,再严格一点可以同时加上字符集过滤。

    public boolean scene2(String userName){
      return CharacterUtil.isAllCJKLetter(userName);
    }

    public boolean scene2(String userName){
      return CharacterUtil.isAllCJKLetter(userName) && CharacterUtil.isGB2312(userName);
    }

四、扩展阅读

字符集和字符编码的内容其实是很多很杂的,前面讲到的只是冰山一角,一个小小的切入点,需要学习的还很多,慢慢积累吧。下面的内容是我在研究过程中的一点点总结和收获,和大家分享一下~

4.1 字符集和字符编码

说明:
Character Set 和 Charset 在中文中都译为“字符集”,但实际上Character Set 仅指代字符集合,而 Charset 常常指代字符集合和字符编码。

  1. 一般来说,一个字符集,对应一套编码方案,比如 ASCII、GB2312 。但也有特例,Unicode 字符集对应的编码方案就不止一套:UTF-8、UTF-16等。
  2. 通常我们所说的 XXX 编码表,实际上既给出了字符集,又给出了字符编码方式。

4.2 GB系列:GB2312、GBK、GB18030

4.3 编码标准:Unicode、ISO 10646、GB13000

历史上存在两个独立的尝试创立单一字符集的组织,即国际标准化组织(ISO)和多语言软件制造商组成的统一码(Unicode)联盟。前者开发的 ISO/IEC 10646 项目,后者开发的统一码项目。因此最初制定了不同的标准。1991年前后,两个项目的参与者都认识到,世界不需要两个不兼容的字符集。于是,它们开始合并双方的工作成果,并为创立一个单一编码表而协同工作。

4.4 Unicode 和 UTF

Unicode 只是一个用来映射字符和数字的标准。至于字符怎样被编码成内存中的字节,由 UTF(Unicode Transformation Formats) 定义,Unicode 本身并不关心。

UTF-8、UTF-16 是两个最流行的 Unicode 编码方案。

也就是说,Unicode 只是个标准,UTF-8 、 UTF-16 是Unicode标准的实现方式,不要混~

4.5 编码方案

ASCII 是典型的单字节编码方案,即使用单个字节(8 bit)表示一个字符。它占用了一个字节的低 7 位,提供 128 个字符的编码。

EASCII 也是单字节编码方案,由 ASCII 扩充而来,它把 ASCII 没用到的最高位也用了,即占用了单个字节的全 8 位,支持 256 个字符。

GB2312GBK 都是双字节编码方案,将两个字节连在一起表示一个字符。不同的是,GB2312 要求两个字节都 >127(最高 bit 位为 1 );GBK 只要求两个字节中的高字节 >127。

GB2312 把 ASCII 字符集里的可显示字符也给重新编了双字节的码,双字节编码的字符就是我们常说的“全角”,ASCII 里的单字节编码的字符就是“半角”。

GB18030 是一二四字节变长编码方案,使用1个/2个/4个字节来表示一个字符,其中单字节编码部分与 ASCII 兼容,双字节编码部分与 GBK 兼容,四字节编码部分为 GB18030 新增规则。

UTF-8 是变长多字节编码,可以使用 1-6 个字节表示一个 Unicode 字符。方案:

  1. 单字节的字符,则字节的最高位设为0,对于英语文本,UTF-8 码只占用一个字节,和 ASCII 码完全相同;
  2. n个字节的字符(n>1),第一个字节(高字节)的前n位设为1,第n+1位设为0,后面每一个字节的前两位都设为10,这n个字节的其余空位填充该字符的 Unicode 码,高位不足补 0 。即:
0xxxxxxx
110xxxxx 10xxxxxx
1110xxxx 10xxxxxx 10xxxxxx
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
......

五、参考资料

上一篇下一篇

猜你喜欢

热点阅读