Ruby 怎么打造一个 ICU (Unicode) 的包装库?

fantasticfears · 2016年02月12日 · 最后由 fantasticfears 回复于 2017年03月21日 · 4195 次阅读

祝大家🐵🎉

这篇文章大概介绍了下 Unicode 带来的一些问题、相关的库和 Ruby 的一些实现,最后是我对这个问题的 2 个疑问。

以下内容有较多英文,力图表达最准确的意思。Unicode 相关的中文翻译有一些陌生和奇异。

Unicode 带来的问题

Ruby 有完整的 Unicode 支持,但是几乎不提供与 Unicode 相关的功能。2.2 的标准库里才添加了 Normalize(把字符分解成 Unicode 的基础组成方式,有 4 种方法,最常见的是 nfc)相关的功能。作为 CJK 的主要用户之一,很多情况下,我们可能会使用中文作为用户名或者名字。这不可避免地会有排序(transliteration,音译)和比较(confusables)的问题。

  • Transliteration:这是 Unicode CLDR(Common Locale) 处理的一个主要问题。常见的用途是排序。ActiveSupport::Inflector.transliterate 即是这个功能。不过中文进去,? 问号出来。
  • Confusables:这个问题很早就出现在域名上,叫做 IDN homograph attack,利用肉眼无法分辨的 Cyrillic(俄罗斯)字符 с 等来替代英文字符 c,所以之后浏览器会在某些情况下显示 xxx-xxx.com 这样的 Punycode。中文也有这样的 Unicode,如 ㍯ → 23点勇 → 勇(Chrome 已经用 ICU 处理过这些字符了,所以你是可以搜索匹配到两个的)。同样的情况,在名字这些重要要件中,这也是应该避免的东西,比如 @ 自动补全功能可能会让用户对错误的人执行无法预期的操作。(利用 unf 做的玩具库

以上问题都是 Unicode 的冰山一角,我们尽量做我们能做的。

已经有的 Gem

  • twitter-cldr:纯 Ruby,在 CLDR 上的实现相当完整。
  • unf:只做 Normarlization,C 写的,很快。不过有了官方库之后就不怎么必要了。
  • charlock_holmes:从 ICU 那里带来了一些功能,速度还行。

其他 Gem 就几乎没有怎么更新了,比如:

ICU

这叫要说到一个处理 Unicode 的集大成者 ICU 了。ICU 是 IBM 专门拿来解决 Unicode 问题的库,C/C++ 和 Java 两种,及其成熟,16+ 年历史了。有挺多语言都有自制的包装库。除此之外,我还能想到 iOS 的 Foundation 里有很多字符相关的功能是用它支持的。

确实有很多软件依赖于它,甚至系统上也很容易找到库文件,OS X 上就有 /usr/lib/libicucore.A.dylib

但是只有 Perl 和 PHP 把 ICU 的支持放在标准库里。JRuby 很幸运地直接用 icu4r 就好了...Swift 倒是对 Unicode 有一个很不错的语言级别的实现。

Ruby Core 对于 String 的考虑

因为是 ICU 的包装,所以这个包装会碰到很多关于 Ruby 底层实现的问题。Ruby 底层用 byte array (char *) 存储 String,字符串本身可以是任意字符(Unicode!)。但这个 byte array 是有不同编码的(UTF-8、UTF-16)。

在 Ruby 1.9 后对于 m17n 的办法是 CSI Model,所以 Ruby 的 String 对象有 encoding 的信息,可以任意转换 encoding,但是在各种情况下都可以作为 String 使用。这就意味着 String#[] 得到的结果一定是一个单一的字符,不论编码。

所以很明显地,这是和 UCS(Universal Coded Character Set,通用字符集)分道扬镳的一种方式。使用 UCS 模型的语言的底层编码类型应该是 UTF-8、UTF-16 和 UTF-32。而大部分语言的实现字符串的方式是 UCS 而不是 CSI。

只是 Ruby 内部实现默认使用了 UTF-8 作为编码方式。

MRI Ruby 没有用 ICU 的原因是要考虑多平台兼容性。

ICU 的内部实现

ICU 选定了 UChar(uint16_t) 作为它的内部字符串的实现,Unicode 叫这个叫Plane 平面。在这个级别上是没有大小端序的问题的。Unicode 字符是可以用 2^22 的点集表示的,所以一个 Unicode code point 是可以用 1、2 个(2 个的叫 surrogates,极少出现的情况)UChar 表示。UChar byte array 不一定是良好的 UTF-16 编码就是因为 surrogate 了。

少部分的功能是有直接的 UTF-8 和 UTF-32 支持的。ICU 也支持 UTF-8 字符串到 UChar byte array 的相互转换。

Ruby 能做到的

作为一个包装库,重要工作之一是把 Ruby String byte array 和 UChar byte array 对接起来。

  • MRI C 这个等级上可以得到 UTF-8 表示的 byte arrays,这个已经可以接上 ICU 的转换函数了。转换回来也不算困难。
  • String#unpack(U*) 转换到的是 Unicode Code Point,这可以转换到 UTF-32。

那么就可以开始考虑如何实现这个包装库了。

包装库的目标和选择

目标:

  • 简单但易于扩展。ICU 是个极其庞大的库,应该尽量降低维护一个包装库的成本,没有人用的功能就留着。
  • 不造大轮子。ICU 的成熟度足够高,再用 Ruby 重写,实在过于复杂了。ICU 也把 Unicode 定义的数据很好地打包了。
  • 优秀的性能。

那么不外乎选择:

  • C extension。这就是 icu4r 做的,问题在于很麻烦,相关的函数太多,包装是件很复杂的事情。有别的选择的话,我不太倾向于这个做法。
  • FFIffi-icu 是我从 jarib 那顺过来的一个 Gem。性能不错,用 Ruby 写起来还算轻松。这里最严重的问题在于 UChar 没有很好的实现,碰到 surrogates 就麻烦了

FFI

FFI 包装了 libffi,虽然说 libffi 都有好几年没更新了,但是这是一个很简单的创建包装库的办法。

FFI 没大问题的应该... 不过写 C-ext 执行更快一点, 而且 FFI gem 本来就是个 C-ext


从 UChar* 生成 Ruby 字符串, 可以直接调 Ruby 的 CAPI rb_str_new(uchar_pointer, uchar_length * 2)

然后在 Ruby 中 str.force_encoding('utf-16le').encode('utf-8')

FFI 没大问题的应该... 不过写 C-ext 执行更快一点, 而且 FFI gem 本来就是个 C-ext

速度上确实如此。FFI 的包装主要在于方便,大部分关于 ICU 的操作只有几个函数调用罢了。除了字符串这部分,看起来是 FFI 最节省时间。而且不用像 icu4r 那样写 n * 1000 规模的 C...

ICU 在日常使用中,系统有的概率不低,这也是用 FFI 的一个优点吧,不用下一个很大的库,也不用再编译了。

从 UChar* 生成 Ruby 字符串, 可以直接调 Ruby 的 CAPI rb_str_new(uchar_pointer, uchar_length * 2)

然后在 Ruby 中 str.force_encoding('utf-16le').encode('utf-8')

UChar 可能是 malformed 的 UTF-16,所以这部分还可能出错,当然作为 gem 内部使用基本不会遇到。但是大小端序这样写似乎是大问题啊...容易碰到跨指令集的问题

最后我有个不情之请,我实在看不懂 Haskell,你知道 Data.Text.ICU 大致是什么做法么?

#2 楼 @fantasticfears ICU 用的 native 大小端序, Ruby 可以像这样简单的检测:

LITTLE_ENDIAN = ([256].pack('S*') == [256].pack('S<*')) # 不然就是 big endian

moving GC 的 Haskell 更麻烦, 得把内容拷到 heap 去

#3 楼 @luikore Ruby 这个 binding 其实也是很麻烦的。大部分的功能,用 C 再对接,写法及其特别的啰嗦。UChar 这个地方如果用 C 写,那么 FFI 就不可能再用了。OS X 这些系统没有 ICU 的头文件,所以起码还得解决各种头文件和版本的问题...

FFI 没大问题的应该... 不过写 C-ext 执行更快一点, 而且 FFI gem 本来就是个 C-ext

我找过几个 benchmark,ffi-icu 的速度相当没有问题,这也是我比较想继续用 FFI 的原因。

实现一个 UChar byte array 作为 ICU 和 Ruby String 的 adapter,简单的实现考虑到复制也并不会很快... ICU 这还有很多自动的类,还做了 cow 的,比如 UnicodeString。这些都没有 extern 给 C,所以 Ruby 的话写 C extension 也不能用...

另外的选择像 Rice 还是不够成熟,原来踩过坑吃过苦头。

#4 楼 @fantasticfears 告诉用户 brew install icu4c 就好, 参见 https://github.com/brianmario/charlock_holmes

其实 mac 自带的 ICU 版本非常的老, 不如 brew install 的新版本好, 如果 iOS 或者桌面程序中用 ICU, 一般都建议编译个新的自己打包

C++ 的 ABI 不管用什么方式都不好处理, exception 一抛你就内存泄漏了, 建议只用 C 的...

最近重新捡起了这个事情,花了一周的时间研究了下。写了一个验证性的实现。Unicode 正规化的效率大概能快 500% https://github.com/fantasticfears/icu4r-next 编译要依靠本地有 icu 的头。把 ICU 链接成静态库很简单,不过还没做。现在暂时写到这个地步,我好像没有动力继续下一步了...欢迎提问。

Unicode 相关的用途和 CJK 用户应该最有关系了,日本社群实现了 CSI 后大概已经不理会处理国际化的问题了...(SHIFT_JIS 是 Unicode 史前编码,转到 UTF-8 可能有数据丢失,by Yehuda) 但是日子还是得过下去。ICU 提供的功能很多,transliterate, normalize, converter, collate, spoof_checker,Unicode 标准里提到的算法都有。twitter-cldr-rb 和 ICU 都用了 Unicode CLDR 的数据,只是 twitter-cldr-rb 用 Ruby 实现,而 ICU 是 C/C++/Java 的实现。不过我实在不想重新造轮子,所以一直想写胶水实现。

我没想到写这个插件这么困难。MRI 内部太太太可怕了。写插件困难的地方在于 MRI 没什么文档,内部 API 随便用,毫无边界,要找到对的 API 就很麻烦了。

然后是编码问题。MRI 的字符串一定是 byte array 加上每个字符串额外存储的编码信息。而 ICU 大部分情况下处理的是 UTF-16,所以一定要转换,为了能控制清楚编码,只好在 C 里面写。ffi 的 API 实在难以处理这种复杂的状态了。

最后内存管理,为了转换编码写了 UChar String 的一个内部类,把 C Struct 包装到 Ruby 类(Ruby 内部有个 Data 的类,BigNumber 也用了这个)。这样既可以用来调用 ICU 的 C API,也可以靠 Ruby GC 来管理生成的内存(MRI 在 C 这个层面是扫寄存器!和栈来追踪有没有东西需要清理)。原来的 icu4r 这个库把类似功能包装出了一个真的 UString 类,用来存 Unicode 字符串,这个库的历史都在 1.9(CSI model)之前了。现在的 String 类功能丰富,做个内部类方便做胶水就好,放出来八成没什么用...

附写插件比较有用的参考:

需要 登录 后方可回复, 如果你还没有账号请 注册新账号