R·ex / Zeng


音遊狗、安全狗、攻城獅、業餘設計師、段子手、苦學日語的少年。

用 OpenResty+OpenCC 讓網站支援正體中文

想要檢視本站的正體中文版本,點選頁尾處的“正體中文”連結即可。

關於這篇文章的背景,真是說來話長……

大概在八年前,我去 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 加入白名單。

這是我們共同度過的

第 3860 天