四字节字符的那点事

zsx in 记录整理 / 3 / 17111

要从“𥊍”这个字说起,因为这个字提了一个Issue(https://github.com/zblogcn/zblogphp/issues/42) 。然后翻找了半天资料才算勉强解决这个问题。

首先,其UTF-8编码是U+2528D,属于CJK Unified Ideographs Extension B(中日韩统一表意文字扩充B)字符集的字符,处于第二辅助平面(SIP,表意文字补充平面),不属于我们通常所见的基本多文种平面(BMP,即Unicode编码范围在0000-FFFF之内)的字符。保存一个字,需要占4 Bytes的字节;相比之下,在BMP范围之内的字符只需要占用3 Bytes。仅仅就因为字符保存位数不同,就让程序开发出现了难题。


先看MySQL。根据MySQL 5.0版本的官方文档所述(https://dev.mysql.com/doc/refman/5.0/en/charset-unicode-utf8.html):

Korean, Chinese, and Japanese ideographs use 3-byte sequences.

也就是说,我们常用的utf8_general_ci这种保存表的方式,其只能以三个字符保存这些数据。出现了像上面这个字一样的字符,其自然便出错了。

那怎么解决呢?用utf8mb4_general_ci来代替utf8_general_ci,就可以让MySQL以四个字符来保存UTF-8数据,其自然就能解决。

可是……


别忙着!utf8mb4_general_ci是在MySQL 5.5.3以上版本才支持的!我们可以看看WordPress的以下代码:

if ( version_compare( $version, '5.5.3', '<' ) ) {

    return false;

}

// .......

/*

 * libmysql has supported utf8mb4 since 5.5.3, same as the MySQL server.

 * mysqlnd has supported utf8mb4 since 5.0.9.

 */

if ( false !== strpos( $client_version, 'mysqlnd' ) ) {

	$client_version = preg_replace( '/^\D+([\d.]+).*/', '$1', $client_version );

	return version_compare( $client_version, '5.0.9', '>=' );

} else {

	return version_compare( $client_version, '5.5.3', '>=' );

}

所以说,如果没有自己配置主机的权限,主机MySQL版本低于5.5.3的话,还是不能使用。

那怎么办呢?


那,当然是强制转换了。将其转换为连数据库都读得懂的编码后,再存入或读出,自然也便可以了。

当然,我们在PHP中可以直接使用bin2hex函数将其直接转换为16进制,通过加装一些壳来界定其边界;读出时直接再一个正则读回来转回二进制输出到页面就是。可是,这未免也太麻烦了。如果我们只是在浏览器中显示这个字符的话,完全可以使用其他方式。

在HTML中,可以使用&#xhhhh;来显示一个字符,hhhh为十六进制。这类字符似乎被称之为HTML Entity,或是HTML实体。我目前只能确认&amp;之类字符算是HTML Entity,&#xhhhh;只看到如Code Hex这样的称呼,无论如何Google都搜索不到比较靠谱的名字,这就不管他了。

这里的这个十六进制,实际上是要用UTF-32UCS-4编码来获取的;用UTF-16得到的编码只适用于BMP内字符,用UTF-8得到的十六进制只适合ASCII字符。这一点不知道坑了我半天。既然知道了,那就好办了。在SQL查询前挂个正则替换,一切就安宁了。

	$sql = preg_replace_callback("/[\x{10000}-\x{10FFFF}]/u", function ($matches) {

		return sprintf("&#x%s;", ltrim(strtoupper(bin2hex(iconv('UTF-8', 'UCS-4', $matches[0]))), "0"));

	}, $sql);

反之,若这个字符不仅仅用于浏览器显示用途,而要在程序内操作的话,也可以反转回来:

	$content = preg_replace_callback("/\&\#x([0-9A-Z]{2,6})\;/u", function($matches) {

	    iconv('UCS-4', 'UTF-8', hex2bin(str_pad($matches[1], 8, "0", STR_PAD_LEFT)));

	}, $content);

比较麻烦的是,PHP < 5.4的版本不支持回调函数和hex2binhttp://php.net/manual/zh/function.hex2bin.php) ,所以可能还要自己去实现一个。不过人PHP官方的评论区里就有,拿来抄就是了。


不过还有更悲惨的事情,那就是在JavaScript中使用。

由于历史原因,JavaScript采用的编码是UCS-2或是UTF-16,直到最近的ECMAScript 6标准才有了对Unicode原生的支持。如果我们要在JavaScript中使用这些非ASCII字符,也就必须将其转换为UCS-2编码。有以下方式可用:

	$sql = preg_replace_callback("/[\x{10000}-\x{10FFFF}]/u", function ($matches) {

            return sprintf("\u%s", substr(implode("\u", str_split(strtoupper(bin2hex(iconv('UTF-8', 'UTF-16', $matches[0]))), 4)), 6));

    }, $sql);

UTF-8转为UTF-16后会多出\uFFEF这个字(非法字符),所以要将其用substr砍掉。

另外更机智的做法是:

	$sql = preg_replace_callback("/[\x{10000}-\x{10FFFF}]/u", function ($matches) {

		return trim(json_encode($matches[0]), '"');

	}, $sql);



总的来说,又是被资料不全 + 坑爹问题坑了一个下午的时间吧,嗯。

如果本文对你有帮助,你可以用支付宝支持一下:

Alipay QrCode
zsx at 2016/2/4[回复]
那个不是,是输入的就是这个字。emoji的确都是这么存储的。
这文法我喜欢!