R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

用 OpenResty+OpenCC 让网站支持正体中文

注意:本文发布于 1528 天前,文章中的一些内容可能已经过时。
想要查看本站的正体中文版本,点击页脚处的“正體中文”链接即可。

关于这篇文章的背景,真是说来话长……

大概在八年前,我去 CTSC 打了个酱油(非集训队,只是去玩的),在那几天里,我接受了许多大佬们的培训,印象比较深刻的是 BYVoid 大佬,因为他在 PPT 中放了自己网站的链接,我点进去之后发现可以随意切换“简体”和“繁体”,而且似乎跟我以前见过的国际化不同,文章内容也会随着切换。当时我对 Web 开发一窍不通,所以没有能力好好研究这究竟是怎么实现的。

后来有一次机缘巧合,我看到了一个叫 OpenCC 的项目(巧合的是,作者就是 BYVoid),这个项目可以把汉字在中国大陆、香港、台湾三种模式下互相转换,对于专用词汇和地区惯用字的转换做的相当不错,而且已经是 Linux 下非常通用的库。后来我又得知,BYVoid 的网站就是利用了 OpenCC 做的转换。

当时的我尝试用 PHP 来调用 OpenCC 的函数,但由于工作量过大(加上我菜),于是也没能实现。后来想到为什么不在 Nginx 直接调用 OpenCC 把页面转换一下再返回给浏览器呢?我查了一下,Nginx 自身虽然没有调用外部函数的功能,但 OpenResty(本质上是 Nginx+Lua)似乎可以。但是由于当时我对 Nginx 也不熟,感觉换成 OpenResty 又要踩一堆坑,于是也作罢了。

去年某一天,我将自己的网站迁移到了一个配置更好的服务器,并且全面 Docker 化(本地开发的时候也在 Docker 上,只是用了另一份配置文件而已),从 Nginx 换成 OpenResty 简直跟换衣服一样轻松;加上自我感觉现在的水平应当足够了,于是便开始了又一次的尝试。

通过一些搜索,我发现已经有人成功使用 OpenResty 提供的 Lua FFI 调用了 OpenCC 的 .so 库,但由于是给付费客户做的,因此不方便公开源码。虽然有点遗憾,但我觉得,既然有了一个可行的思路,那我一定能搞出来。

在 Docker 中搭建 OpenResty+OpenCC

这应当是全文最容易理解的部分了。简而言之,要想达成目标,我们首先需要有一个 OpenResty 和 OpenCC。经过搜索,我找到了 hustshawn/openresty-opencc-docker 这个项目。

看起来它的 Dockerfile 没什么问题,只是 OpenResty 的版本稍微旧了一些,于是我 Fork 了一份、改了一下版本后,打成了自己的一个 Docker 镜像 rexskz/openresty-opencc-docker。要想启动起来,只需要在 compose.yml 中这样写(之所以 name 还叫 nginx,因为它本质上就是个 Nginx,就好比 MariaDB 的可执行程序依旧叫 mysql):

services:
  nginx:
    image: rexskz/openresty-opencc-docker
    container_name: nginx
    hostname: nginx
    # ....省略其它的配置

启动容器后进去转了一圈,发现 OpenCC 相关的所有数据文件都在 /usr/share/opencc/ 目录下,由于我想把简体中文转换为台湾的正体中文,并需要转换专用词汇和地区惯用字,因此需要加载其中的 s2twp.json 文件。

了解 OpenResty 的一些基础知识

OpenResty 之所以可以做到许多事情,因为它内置了一个 LuaJIT,可以使用 Lua 这个脚本语言来自定义处理逻辑,例如 CloudFlare 就用它来做 WAF。我们这次的目标很单纯——如果发现有 cc_lang=zh_TW 这个 Cookie,就将 Response 中的汉字转换为台湾的正体中文。

经过查阅 OpenResty 的文档,我发现有两个指令可以满足我的需求:body_filter_by_lua_blockbody_filter_by_lua_file(至于 body_filter_by_lua,文档说已经不推荐使用了),前者是直接将 Lua 代码写到 Nginx 配置文件中,后者是写到特定的 Lua 文件中。我选择了后者,因为后者在开发环境中会更好用一些:可以在 Nginx 配置文件中添加 lua_code_cache off 指令,每次修改了 Lua 文件后无需 nginx -s reload

了解 OpenResty 的 Lua 文件

OpenResty 之所以可以调用 Lua 来修改请求和响应,是因为在 LuaJIT 中,可以通过一个叫 ngx 的全局变量来获取 Nginx 的数据或者与 Nginx 互动,例如在 body_filter_by_lua 中加载的 Lua 脚本:

  • 可以用 ngx.arg[1] 获取 Nginx 的 Response body;
  • 可以用 ngx.var['cookie_cc_lang'] 获取名为 cc_lang 的 Cookie 值;
  • 可以用 ngx.log(ngx.NOTICE, "xxx") 往 Nginx 的 Notice 级别的 Log 中输出一句话。

完整的 ngx API 在这里:Lua Ngx API - OpenResty Reference

至于 Lua 的 FFI,看了文档之后也发现是相当简单,只需要如下几步:

  1. require('ffi')(废话);
  2. 定义函数签名,用的是 C 的语法;
  3. 通过一条命令加载动态链接库;
  4. 直接跟 JavaScript 一样调用即可。

要想完成本文的目标,只需看 FFI · OpenResty最佳实践 这一个页面就足够了。

了解 OpenCC 的 C API

所有可以通过 FFI 调用的 C API 在这里:Open Chinese Convert: OpenCC C API,只有非常少的几个函数,通过描述我们大概可以得出调用顺序:

opencc_t inst = opencc_open("/usr/share/opencc/s2twp.json");

// 文字是 utf8 编码
char *input = "这是一段文字";
char *output = opencc_convert_utf8(inst, input, strlen(input));

// 当然也可以用这一种思路:即提前分配好 output 的空间
// char *output = (char *)malloc(sizeof(char) * strlen(input) + 1);
// opencc_convert_utf8_to_buffer(inst, input, strlen(input), output);

// 想办法把 output 保存起来
....

// 最后需要做一些清理,否则可能会内存泄漏
opencc_convert_utf8_free(output);
opencc_close(inst);

开始正式的实践

理清思路之后,我们就可以开始写了!首先新建一个 nginx-opencc-filter.lua 的文件,并将其挂载到 OpenResty 容器中:

services:
  nginx:
    volumes:
      # 这是我网站的配置文件
      - ./nginx-docker-live.conf:/path/to/rexskz.conf
      # 这是刚刚新建的 .lua 文件
      - ./nginx-opencc-filter.lua:/path/to/opencc-filter.lua
    # ....省略其它的配置

然后在 OpenResty 配置文件中加载这个 Lua 文件:

# 只为你需要转换的页面添加这个配置,不要所有的 location 都加
location /your-page {
    body_filter_by_lua_file /path/to/opencc-filter.lua;
    # ....省略其它的配置
}

接下来开始写 Lua 脚本了!至于 Lua 的简单语法,网上有很多,这儿就不赘述了。整个脚本是这样的,基本的思路跟刚才写的 C 代码差不多,应当非常好理解:

local cookie_value = ngx.var['cookie_cc_lang']

if cookie_value == 'zh_TW' then
    local ffi = require('ffi')

    ffi.cdef[[
        typedef void* opencc_t;

        opencc_t opencc_open(const char *configFileName);
        int      opencc_close(opencc_t opencc);
        char*    opencc_convert_utf8(opencc_t opencc, const char *input, size_t length);
        void     opencc_convert_utf8_free(char *str);
    ]]

    local cc = ffi.load('opencc')

    local inst = cc.opencc_open('/usr/share/opencc/s2twp.json')
    local input = ngx.arg[1]
    local res = cc.opencc_convert_utf8(inst, input, #input)

    ngx.arg[1] = ffi.string(res)

    cc.opencc_convert_utf8_free(res)
    cc.opencc_close(inst)
end

添加了 Cookie 之后刷新一下,发现页面已经是正体中文版本了!之后的操作就是在页面中适当的地方加上简体中文和正体中文的链接。

由于 Buffer 导致的 Bug

奇怪的是,不是所有的页面都可以成功转换,有些页面进去之后只能加载很少的一部分文本,甚至有的页面直接提示 ERR_INCOMPLETE_CHUNKED_ENCODING。回去看 Nginx 的 Log,发现只要一访问这样的页面,就会多一句 worker process 8 exited on signal 11,我看了半天,感觉 Lua 脚本不应该有 Bug,但 OpenResty 的 Lua 脚本又不方便单步调试,于是我祭出了八年前就熟练掌握的输出调试大法,在一开始先输出 ngx.arg[1],保存刷新,于是发现了一堆 UTF-8 下面的乱码……没错,就是黑色菱形里面带一个问号的那种。

一次正常的请求,会输出一次或多次,每一次都没有这样的乱码;但对于不正常的请求,一定会在最后一次输出这样的乱码,然后 Worker process 就崩溃了。经验告诉我,一定是因为 Nginx 为了加快速度,对请求做了流式处理,将请求和响应切分成若干个 Chunk 发送给了 Lua 脚本。一旦切分的位置恰好在一个汉字(占 3 个字节)的中间,那么这个汉字就会被切分成两个乱码,被分散到了两个 Chunk 中。

OpenCC 不需要对这种特殊情况做兼容,而且即使做了兼容,也无法转换这个字(甚至这个字所在的词语),因此我们能做的就是让 Nginx 不切分页面的 Response body。由于我的网站是 FastCGI + PHP-FPM(上古时期的技术了),因此只需要调大一下 Buffer 的值即可(直接设置 fastcgi_buffering off 我试了似乎没用,不知道为什么):

fastcgi_buffers           8 256k;
fastcgi_buffer_size       256k;
fastcgi_busy_buffers_size 512k;

对于使用 proxy_pass 代理的情况,有等价的 proxy_buffer_size 等指令,用法完全一样。

于是……我的网站就这样支持了正体中文。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3088 天