如何寫一個 GNU 風格的命令列程式

garfileo發表於2019-05-11

GNU Autotools 不黑

我見過很多人抱怨 GNU Autotools 太複雜。事實上它比求解一元二次方程簡單多了。學習 GNU Autotools,最好的辦法就是動手搭建一個專案,然後根據自己的需求去查 GNU Autotools 的文件與教程,整個過程只是很簡單的『加法』運算。

也有人抱怨 GNU Autotools 在 Windows 下不好用。這是事實,不過 GNU Autotools 只是為了類 Unix 系統設計的,也可以說 GNU Autotools 是為了 GNU 這個生態系統設計。錯誤的使用 GNU Autotools,要是還好用,那就太奇怪了。

如果你確定 GNU Autotools 是你所需要的,那麼我毫不吝惜的告訴你,我見過的最好的 GNU Autotools 入門教程在 https://www.lrde.epita.fr/~adl/autotools.html,這是一個演示文件風格的教程。如果你稍微有點你耐心,注意力再集中一些,半天功夫應該能夠掌握 GNU Autotools 全部知識的七成了,而剩下那三成一般也很少用到。

我這篇文章主要講一下,如何藉助 C 語言、GNU Autotools 以及 GLib 庫構建一個 GNU 風格的國際化的命令列程式。由於這個程式是我的一個小專案需要的,所以我有耐心指導我自己如何完成這件事。也就是說,在我開始寫這份文件時,我剛開始認真的學習 GNU Autotools 以及 GLib 庫的部分功能。不過,這也意味著這篇文件可能會很長。

還需要叮囑一下,這篇文件是我寫給我自己看的,即使你看到有些怪異之處,只當做情節純屬虛構,看不下去也無需自尋煩惱。

月亮

我要構建的這個程式名曰 moon,它屬於 zero 專案。不要問為什麼,因為它就應該叫 moon。於是我建立了 zero/src 目錄與 moon.c 檔案:

$ mkdir -p zero/src
$ cd zero
$ echo "int main(void) {
        return 0;
}" > src/moon.c

也不要問我為什麼是在 zero 的 src 目錄中建立了 moon.c 檔案,因為即使我告訴你這個專案的名字叫 zero,並且 zero 取『道』之意,而 moon 取『陰』之意,你會覺得我在開玩笑,可是我沒有。

造月亮的原料

現在,我執行以下命令:

$ echo "AC_INIT(zero, 0.1, garfileo@gmail.com)
AC_CONFIG_AUX_DIR([build-aux])
AC_CONFIG_MACRO_DIR([m4])

AM_INIT_AUTOMAKE([foreign -Wall])
AC_PROG_CC
PKG_CHECK_MODULES(WHEEL, [glib-2.0])

AC_CONFIG_HEADERS([config.h])
AC_CONFIG_FILES([Makefile src/Makefile])
AC_OUTPUT" > configure.ac
$ mkdir m4

上述命令所完成的任務就是在 zero 目錄中建立了 configure.ac 檔案,然後將一組以 AC_, AM_ 以及 PKG_ 為字首的語句寫入該檔案。

configure.ac 檔案其實是 autoconf 的配置檔案,它指導著 autoconf 如何生成 configure 指令碼。如果不清楚 autoconf 是如何工作的,可閱讀『Autoconf 的基本原理』。

這些以 AC_, AM_ 以及 PKG_ 為字首的語句都是 m4 巨集的呼叫。如果你不知道 m4 是什麼,可閱讀『讓這世界再多一份 GNU m4 教程』。

GNU Autotools 是一個工具集,其中比較重要的工具有 autoconf, aclocal, automake, libtool,此外還有一些輔助工具,例如 autoscan, autoheader 之類。還有一個工具 pkg-config ,雖然它不屬於 GNU Autotools,但也是非常重要。這些工具提供了一些可在 configure.ac 檔案中呼叫的 m4 巨集。例如,以 AC_ 為字首的巨集都是 autoconf 提供的,以 AM_ 為字首的巨集是 automake 提供的,以 PKG_ 為字首的巨集是 pkg-config 提供的。所以,要想弄明白這些巨集的含義,就使用 info 去查各個工具的手冊。例如,要弄清楚 AC_CONFIG_AUX_DIR,就需要 info autoconf。如果不懂 info 命令的用法,那麼你應該 info info

既然在 AC_CONFIG_FILES 巨集引數中設定將來要通過 configure 指令碼生成 Makefile 與 src/Makefile 檔案,那麼就必須提供相應的 Makefile.am 與 src/Makefile.am 檔案:

$ echo "ACLOCAL_AMFLAGS = -I m4
SUBDIRS = src" > Makefile.am
$ echo "bin_PROGRAMS = moon
moon_CFLAGS  = $(WHEEL_CFLAGS) -std=c99
moon_LDADD   = $(WHEEL_LIBS)
moon_SOURCES = moon.c" > src/Makefile

這樣,最基本的 zero 專案就搭建了起來,現在可以造個黯淡無光的月亮了:

$ autoreconf -i
$ mkdir build
$ cd build
$ ../configure
$ make
$ src/moon   # 不會輸出任何東西,因為它是個空殼子

也可以將 moon 安裝到系統中:

$ sudo make install
$ moon

如果後悔了,還可以解除安裝:

$ sudo make uninstall

命令列選項解析

現在,我希望 moon 程式能夠支援下面這種呼叫形式:

$ src/moon --entrance="程式碼的提取入口" --output=foo.c foo.zero

$ src/moon -e "程式碼的提取入口" -o foo.c foo.zero

要在 C 程式中解決這個問題,直接呼叫 GLib 庫中的命令列選項解析器是最輕省的辦法。我將前文中那個什麼也沒做的 src/moon.c 修改為:

#include <locale.h>
#include <glib.h>

static gchar *zero_entrance = "*";
static gchar *zero_output   = "zero-output.c";

static GOptionEntry moon_entries[] = {
        {"entrance", `e`, 0, G_OPTION_ARG_STRING, &zero_entrance,
         "Set <chunk> as the entrance for extracting code.", "<chunk>"},
        {"output", `o`, 0, G_OPTION_ARG_STRING, &zero_output,
         "place output into <file>.", "<file>"},
        {NULL}
};

int main(int argc, char **argv) {
        setlocale(LC_ALL, "");
        GOptionContext *context = g_option_context_new("zero-file");
        g_option_context_add_main_entries(context, moon_entries, NULL);
        if (!g_option_context_parse(context, &argc, &argv, NULL)) {
                g_error("Commandline option parser failed!");
        }
        if (argv[1] == NULL) g_error ("You should give me zero file!");
        g_print("%s
", zero_entrance);
        g_print("%s
", zero_output);
        g_print("%s
", argv[1]);
        return 0;
}

然後重新編譯這個專案,然後執行新的 moon:

$ make
$ src/moon --entrance="程式碼的提取入口" --output=foo.c foo.zero
程式碼的提取入口
foo.c
foo.zero
$ src/moon -e "程式碼的提取入口" -o foo.c foo.zero
程式碼的提取入口
foo.c
foo.zero
$ src/moon -h
Usage:
  moon [OPTION...] zero-file


Help Options:
  -h, --help                 Show help options

Application Options:
  -e, --entrance=<chunk>     Set <chunk> as the entrance for extracting code.
  -o, --output=<file>        place output into <file>.
$ src/moon 

** (moon:21159): ERROR **: You should give me zero file!
fish: Job 2, “src/moon” terminated by signal SIGTRAP (Trace or breakpoint trap)

GLib 庫的命令列解析器是如何做到這一切的呢?

首先,我定義了兩個全域性變數:

static gchar *zero_entrance = "*";
static gchar *zero_output   = "zero-output.c";

然後用 GLib 庫提供的 GOptionEntry 結構將這兩個全域性變數與一個命令列選項陣列 moon_entries 中的 2 個元素關聯起來:

static GOptionEntry moon_entries[] = {
        {"entrance", `e`, 0, G_OPTION_ARG_STRING, &zero_entrance,
         "Set <chunk> as the entrance for extracting code.", "<chunk>"},
        {"output", `o`, 0, G_OPTION_ARG_STRING, &zero_output,
         "place output into <file>.", "<file>"},
        {NULL}
};

至於 GOptionEntry 各個成員的含義,請自行查閱 GLib 手冊的『Commandline option parser』部分。

接下來,在 main 函式中,使用 g_option_context_new 建立命令列選項環境 context,並順便設定這個程式所接受的引數資訊為 zero-file。這個引數與 moon_entries 中定義的命令列選項無關,它是程式的引數,不是程式的選項的引數。

GOptionContext *context = g_option_context_new("zero-file");

正是因為我設定了 moon 的引數為 zero-file,所以在執行 moon -h 時會得到以下資訊:

$ src/moon -h
Usage:
  moon [OPTION...] zero-file

... ... ...

接下來,就是將 moon_entries 陣列新增到命令列選項環境 context 中:

g_option_context_add_main_entries(context, moon_entries, NULL);

然後就可以對命令列進行解析了,即:

if (!g_option_context_parse(context, &argc, &argv, NULL)) {
        g_error("Commandline option parser failed!");
}

如果解析失敗,就報錯。

g_option_context_parse 函式首先從 argv 中擷取符合命令列選項陣列成員相符的文字,然後進行解析,將所得引數值賦予相應的變數。在本文的示例中,若我像下面這樣執行 moon 命令:

src/moon --entrance="程式碼的提取入口" --output=foo.c foo.zero

那麼 main 函式的 argv 的內容一開始是:

argv[0]: src/moon
argv[1]: --entrance="程式碼的提取入口"
argv[2]: --output=foo.c
argv[3]: foo.zero

g_option_context_parse 函式會擷取 argv[1]argv[2] 進行解析,將所得的值分別賦給 zero_entrancezero_output。它這樣一搗亂,argv 的內容就變成了:

argv[0]: src/moon
argv[1]: foo.zero

如果你理解了上述過程,那麼下面程式碼的含義就無需多做解釋了。

if (argv[1] == NULL) g_error ("You should give me zero file!");
g_print("%s
", zero_entrance);
g_print("%s
", zero_output);
g_print("%s
", argv[1]);

真正還需要解釋的是

#include <locale.h>
setlocale(LC_ALL, "");

的作用。

如果 src/moon.c 沒有這兩行程式碼,那麼 g_print 可能就沒法正確的顯示中文。setlocale(LC_ALL, "") 的意思是對系統 Locale 不作任何假設,這樣 moon 程式的 Locale 就會因系統中的 Locale 環境變數的值而異。

我的系統中的 Locale 環境變數的值如下:

$ locale
LANG=en_US.UTF-8
LC_CTYPE=en_US.UTF-8
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_PAPER="en_US.UTF-8"
LC_NAME="en_US.UTF-8"
LC_ADDRESS="en_US.UTF-8"
LC_TELEPHONE="en_US.UTF-8"
LC_MEASUREMENT="en_US.UTF-8"
LC_IDENTIFICATION="en_US.UTF-8"
LC_ALL=

雖然我的 Locale 環境變數的值都是 en_US.UTF-8,但是它所容納的字元編碼與 zh_CN.UTF-8 是一樣的,所以我的系統能夠正確的顯示中文字元。

為什麼我不將系統的 Locale 變數都設成 zh_CN.UTF-8 呢?因為這樣做,會讓很多支援國際化的程式的輸出結果中出現從英文翻譯過來的中文資訊,而這些中文資訊所表達的內容往往不如英文原文準確。

為所有的人制造一個月亮

現在,moon 能夠支援命令列了,但是 moon -h 顯示的幫助資訊都是英文的。雖然我知道,中國同胞們並沒有很多人喜歡命令列程式——他們看到命令列,本能的就希望能有個圖形視窗介面版本。不過,也許……可能……大概會有人需要 moon -h 在中文環境中能輸出中文的幫助資訊,同時,也大概會有人需要 moon -h 在日文環境中輸出日文的幫助資訊。此類需求,不一而足。

為每種語言開發一個專門的版本,以前微軟喜歡幹這種事……有錢,任性!更節約能源的做法是開發一個軟體,讓它有能力支援各種語言。能實現這一目的方法有很多,但是藉助 GNU gettext 工具會讓這件事輕鬆很多,幾乎是零成本的就能構造出一個國際化的 moon。下面說說我的做法。

首先,將 zero/configure.ac 修改為:

AC_INIT(zero, 0.1, garfileo@gmail.com)
AC_CONFIG_AUX_DIR([build-aux])
AC_CONFIG_MACRO_DIR([m4])

AM_INIT_AUTOMAKE([foreign -Wall])
AM_GNU_GETTEXT_VERSION([0.19.7])
AM_GNU_GETTEXT([external])
AC_PROG_CC
PKG_CHECK_MODULES(WHEEL, [glib-2.0])

AC_CONFIG_HEADERS([config.h])
AC_CONFIG_FILES([Makefile src/Makefile])
AC_OUTPUT

也就是在原來的基礎上增加了:

AM_GNU_GETTEXT_VERSION([0.19.7])
AM_GNU_GETTEXT([external])

不要問我為什麼要這麼做,因為我也不清楚,但是如果我想搞清楚,我自然會去 info Automake 文件,或者去看 GNU Autotools 入門教程

接下來,在 zero 目錄中執行 gettextize 命令:

$ gettextize --copy --no-changelog

gettextize 可以將一些有助於你的軟體包支援國際化的檔案複製到當前目錄(zero 目錄),然後它提示你應將 /usr/share/gettext/gettext.h 檔案複製到專案原始碼目錄。你需要敲一下Enter鍵,告訴 gettextize 你知道了。

gettextize 還會自動的將 configure.ac 檔案中的

AC_CONFIG_FILES([Makefile src/Makefile])

修改為:

AC_CONFIG_FILES([Makefile src/Makefile po/Makefile.in])

這意味著,以後在執行 configure 指令碼時,會自動在 po 目錄中生成一份 Makefile。這份 Makefile 檔案能夠自動的幫我們完成一些與國際化有關的繁瑣的任務。

接下來,就複製 gettext.h 到 zero/src 目錄:

$ cp /usr/share/gettext/gettext.h src

然後將 zero 目錄中的 Makefile.am 修改為:

ACLOCAL_AMFLAGS = -I m4    
SUBDIRS = po src

也就是告訴未來的 Makefile,與 zero 專案有關的國際化檔案都在 po 目錄內。

再將 src/Makefile.am 檔案修改為:

AM_CPPFLAGS = -DLOCALEDIR="$(localedir)"
bin_PROGRAMS = moon
moon_CFLAGS  = $(WHEEL_CFLAGS) -std=c99
moon_LDADD   = $(WHEEL_LIBS)
moon_SOURCES = moon.c
LDADD = $(LIBINTL)

所做的修改就是告訴未來的 Makefile,應該將一些與 moon 相關的國際化檔案安裝到系統中的哪個位置,並且將哪些與國際化有關的庫連線到 moon 程式中。

接下來,進入 zero/po 目錄從基於 Makevars.template 建立 Makevars 檔案:

$ cd po
$ cp Makevars.template Makevars
$ sed -i "s/COPYRIGHT_HOLDER = Free Software Foundation, Inc/COPYRIGHT_HOLDER = Garfileo/g" Makevars
$ sed -i "s/MSGID_BUGS_ADDRESS =/MSGID_BUGS_ADDRESS = $(PACKAGE_BUGREPORT)/g" Makevars

兩行 sed 命令是我故弄玄虛,實際上我不想囉哩囉嗦的說,你用你最喜歡的文字編輯器開啟 Makevars 檔案,然後將其中的

COPYRIGHT_HOLDER = Free Software Foundation, Inc
MSGID_BUGS_ADDRESS =

改為:

COPYRIGHT_HOLDER = Garfileo.
MSGID_BUGS_ADDRESS = $(PACKAGE_BUGREPORT)

COPYRIGHT_HOLDER 表示這個專案的責任者,我叫 Garfileo,所以我這麼設定。至於將 MSGID_BUGS_ADDRESS 的值設定為 $(PACKAGE_BUGREPORT),這有點莫名其妙,因為我們從未定義過 PACKAGE_BUGREPORT 變數。我告訴你,整個 Autotools 工具鏈,一直在沒停下來玩悄悄的給你生成一大堆環境變數的把戲,你從了它就好了。PACKAGE_BUGREPORT 的值實際上就是 configure.ac 中的 AC_INIT 巨集的第三個引數……趁機回顧一下 configure.ac 中的 AC_INIT 巨集吧。

再接下來,你應該將專案中凡是含有需要進行國際化的文字的 C 檔案寫入到 POTFILES.in 中。對於 zero 專案,只需:

$ echo "src/moon.c" >> POTFILES.in

現在開始對 moon.c 中的某些文字進行國際化處理。將 src/moon.c 修改為:

#include <config.h>
#include <locale.h>
#include <glib.h>
#include "gettext.h"
#define _(string) gettext(string)

static gchar *zero_entrance = "*";
static gchar *zero_output   = "zero-output.c";

static GOptionEntry moon_entries[] = {
        {"entrance", `e`, 0, G_OPTION_ARG_STRING, &zero_entrance,
         _("Set <chunk> as the entrance for extracting code."), "<chunk>"},
        {"output", `o`, 0, G_OPTION_ARG_STRING, &zero_output,
         _("place output into <file>."), "<file>"},
        {NULL}
};

int main(int argc, char **argv) {
        setlocale(LC_ALL, "");
        bindtextdomain(PACKAGE, LOCALEDIR);
        textdomain(PACKAGE);
    
        GOptionContext *context = g_option_context_new("zero-file");
        g_option_context_add_main_entries(context, moon_entries, NULL);
        if (!g_option_context_parse(context, &argc, &argv, NULL)) {
                g_error("Commandline option parser failed!");
        }
        if (argv[1] == NULL) g_error (_("You should give me zero file!"));
        g_print("%s
", zero_entrance);
        g_print("%s
", zero_output);
        g_print("%s
", argv[1]);
        return 0;
}

比未進行國際化時的 src/moon.c 相比,新的 src/moon.c 增加了以下幾行程式碼:

#include <config.h>
... ...
#include "gettext.h"
#define _(string) gettext(string)
... ...
        bindtextdomain(PACKAGE, LOCALEDIR);
        textdomain(PACKAGE);
... ...

config.h 是 configure 指令碼自動生成的,裡面定義了一些 C 語言巨集。例如,前面談到的 PACKAGE_BUGREPORT 就定義於其中。

gettext.h 是剛才我從 /usr/share/gettext 目錄中複製過來的。

_ 是一個巨集,它的引數是 C 字串。在國際化的 src/moon.c 中,我用這個巨集將三個字串給國際化了,即:

_("Set <chunk> as the entrance for extracting code.")
_("place output into <file>.")
_("You should give me zero file!")

這個巨集是個翻譯家,它可以將英文文字翻譯為另外一種特定語言的文字。

bindtextdomain 函式,可以將系統中的一個存放著國際化檔案的目錄 LOCALEDIR 與當前要建立的軟體包 PACKAGE 關聯起來。也就是說,一旦 PACKAGE 被建立了出來,它在執行時,會使用 textdomain 函式從 LOCALEDIR 目錄取出國際化檔案的內容來用。LOCALEDIRPACKAGE 的值均定義於 config.h 檔案中。

現在,理論上 moon 已經支援國際化了,編譯一下看看。由於剛才修改了 Autoconf 與 Automake 的配置檔案,所以需要在 zero 目錄內重新配置一下環境,然後再編譯 moon,即:

$ autoreconf -i
$ cd build
$ ../configure
$ make
... ...
In file included from ../../src/gettext.h:25:0,
                 from ../../src/moon.c:4:
../../src/moon.c:5:19: error: initializer element is not constant
 #define _(string) gettext(string)
                   ^
... ...

結果在編譯 moon 時出錯了,編譯器提示:在 src/moon.c 檔案中出現了多處『初始化元素不是常量』的錯誤。這事怪不得 GNU gettext,而是語法錯誤。要理解這些錯誤,不妨編譯一下這份 C 程式碼:

#include <stdio.h>

char *foo = "foo";
char *bar = "bar";

char * foo(void) {
        return foo;
}

char * bar(void) {
        return bar;
}

int main(void) {
        char *messages[] = {foo(), bar()};
        return 0;
}

錯誤的根源在於:C 語言無法通過函式呼叫的方式對字串陣列進行初始化。這是因為字串陣列的值是在編譯期間確定的,而函式的呼叫卻發生在程式執行時。

這事看上去無解了。因為我們要用 GLib 庫的命令列選項解析器,必須得初始化一個含字串的陣列,但是 C 編譯器堅持說你用 _(string) 巨集就是不行。

既然是 GLib 自己惹的事,還是讓 GLib 來解決吧。請在 src/moon.c 檔案中增加一個標頭檔案 gi18n.h,然後將那些無法通過編譯的 _(string) 巨集都換成 N_(string),即:

#include <config.h>
#include <locale.h>
#include <glib.h>
#include <glib/gi18n.h>
#include "gettext.h"
#define _(string) gettext(string)

static gchar *zero_entrance = "*";
static gchar *zero_output   = "zero-output.c";

static GOptionEntry moon_entries[] = {
        {"entrance", `e`, 0, G_OPTION_ARG_STRING, &zero_entrance,
         N_("Set <chunk> as the entrance for extracting code."), "<chunk>"},
        {"output", `o`, 0, G_OPTION_ARG_STRING, &zero_output,
         N_("place output into <file>."), "<file>"},
        {NULL}
};

int main(int argc, char **argv) {
        setlocale(LC_ALL, "");
        bindtextdomain(PACKAGE, LOCALEDIR);
        textdomain(PACKAGE);
    
        GOptionContext *context = g_option_context_new("zero-file");
        g_option_context_add_main_entries(context, moon_entries, NULL);
        if (!g_option_context_parse(context, &argc, &argv, NULL)) {
                g_error("Commandline option parser failed!");
        }
        if (argv[1] == NULL) g_error (_("You should give me zero file!"));
        g_print("%s
", zero_entrance);
        g_print("%s
", zero_output);
        g_print("%s
", argv[1]);
        return 0;
}

這樣就可以消除全部的編譯錯誤。

含字串的陣列並沒有變,為什麼 N_(string) 就神通廣大的讓程式碼通過了編譯?因為 GLib 定義的 N_(string) 巨集是這樣的:

#define N_(strging) (string)

也就是說,N_(string) 只是一個標記,這個標記只是告訴 GNU gettext 的某個負責掃描 po/POTFILES.in 中所記錄的 C 檔案的那個工具,它包含了一個要進行國際化的文字,但是不進行翻譯。具體的翻譯過程是在 GLib 命令列解析器內部進行的。

不過,雖然 _(string) 引起的編譯錯誤消除了,但是編譯器給出了一個警告:

./../src/moon.c:6:0: warning: "_" redefined
 #define _(string) gettext(string)
 ^
 In file included from ../../src/moon.c:4:0:
/usr/include/glib-2.0/glib/gi18n.h:26:0: note: this is the location of the previous definition
 #define  _(String) gettext (String)
 ^

警告資訊提示,_(string) 巨集被重複定義了,在 glib/gi18n.h 中已經定義了這個巨集。既然如此,那就從 src/moon.c 中刪除 _(string) 的定義語句。最終的 moon.c

make 過程無錯誤亦無警告時,這就意味著我們的 moon 已經普照世界了,最終的 src/moon.c 內容如下:

#include <config.h>
#include <locale.h>
#include <glib.h>
#include <glib/gi18n.h>
#include "gettext.h"

static gchar *zero_entrance = "*";
static gchar *zero_output   = "zero-output.c";

static GOptionEntry moon_entries[] = {
        {"entrance", `e`, 0, G_OPTION_ARG_STRING, &zero_entrance,
         N_("Set <chunk> as the entrance for extracting code."), "<chunk>"},
        {"output", `o`, 0, G_OPTION_ARG_STRING, &zero_output,
         N_("place output into <file>."), "<file>"},
        {NULL}
};

int main(int argc, char **argv) {
        setlocale(LC_ALL, "");
        bindtextdomain(PACKAGE, LOCALEDIR);
        textdomain(PACKAGE);

        GOptionContext *context = g_option_context_new(_("zero-file"));
        g_option_context_add_main_entries(context, moon_entries, NULL);
        if (!g_option_context_parse(context, &argc, &argv, NULL)) {
                g_error("Commandline option parser failed!");
        }
        if (argv[1] == NULL) g_error (_("You should give me zero file!"));
        g_print("%s
", zero_entrance);
        g_print("%s
", zero_output);
        g_print("%s
", argv[1]);
        return 0;
}

中國的月亮

全世界人看到的是同一個月亮,但是各個國家、各個民族甚至個別的人對這個月亮的稱謂卻有不同。每一種稱謂又都是在某種文化中出現的,既然同樣一個月亮出現了不同的稱謂,那麼世界上那麼多的文化是不是也是描述的同一事物呢?

老子說,是這樣的……無名,萬物之母也;有名,萬物之始也。故恆無慾也,以觀其妙;恆有欲也,以觀其所徼。此兩者同出而異名,同謂之玄。玄之又玄,眾妙之門。

中國的月亮與外國的月亮,沒有什麼區別,只有文字與故事上的差異,而這些差異皆是人為,與月亮無關。moon 程式的國際化也是這樣,moon 的使用者們看到的都是同一個 moon 程式,但是他們是通過 moon 幫助資訊的不同的語言版本來理解 moon 的。他們對 moon 的所有理解只不過是表象,moon 本身才是玄妙的東西,而這種玄妙的東西是我創造出來的……雖然我如此厲害,也只不過是個門而已,那些通過 moon 的幫助資訊而學會了 moon 的人,他們可能比我更會用 moon。也就是說,我比他們更懂 moon,但是並不意味這我比他們更會用 moon。只有懂玄之又玄,眾妙之門的那個人更厲害一些,但是他依然是個門。

為了能夠讓更多的中國人比我更會用 moon,所以我需要為他們製作中文版本的 moon 幫助資訊。用術語描述這個過程,就是 moon 程式的本地化

要做 moon 程式的本地化,需要先進入 zero/po 目錄,你會看到 zero.pot 這個檔案。這個檔案是在 zero 專案的 make 過程中由 xgettext 工具掃描 src/moon.c 檔案自動生成。至於 xgettext 為什麼會自動掃描 src/moon.c 而不是其他檔案,並非是因為 zero 專案中只有 src/moon.c 這麼一個 C 檔案,而是因為在 moon 國際化階段,我們向 po/POTFILES.in 中寫入了 src/moon.c,而 xgettext 正是根據 po/POTFILES.in 檔案中制定的檔案進行掃描的。

xgettext 工具會從 src/moon.c 中掃描什麼?它會掃描那些形如 N_(string)_(string) 之類的文字,並認定這些文字都是國際化文字,然後它會將這些文字以及它們在 src/moon.c 中的位置等資訊寫入 po/zero.pot 檔案。現在,開啟 po/zero.pot 檔案,可以看到以下內容:

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR Garfileo.
# This file is distributed under the same license as the zero package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: zero 0.1
"
"Report-Msgid-Bugs-To: garfileo@gmail.com
"
"POT-Creation-Date: 2016-01-17 16:20+0800
"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE
"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>
"
"Language-Team: LANGUAGE <LL@li.org>
"
"Language: 
"
"MIME-Version: 1.0
"
"Content-Type: text/plain; charset=CHARSET
"
"Content-Transfer-Encoding: 8bit
"

#: src/moon.c:12
msgid "Set <chunk> as the entrance for extracting code."
msgstr ""

#: src/moon.c:14
msgid "place output into <file>."
msgstr ""

#: src/moon.c:20
msgid "zero-file"
msgstr ""

#: src/moon.c:25
msgid "You should give me zero file!"
msgstr ""

對 moon 程式進行本地化,實際上就是基於 po/zero.pot 產生一份 po/zh_CN.po 檔案,即:

$ cd po
$ msginit -l zh_CN

msginit 程式所做的工作主要是將 zero.pot 複製為 zh_CN.po,並對 zh_CN.po 的檔案首部資訊進行初始化——例如,msginit 在執行時會停下來,讓我告訴它我的 Email 地址。我告訴它 garfileo@gmail.com 之後,它才開始繼續工作。

接下來就是用文字編輯器修改 po/zh_CN.po 檔案,主要工作就是設定字元編碼,然後將檔案中非空的 msgid 對應的字串翻譯成中文字串。我將 po/zh_CN.po 修改為:

# Chinese translations for zero package.
# Copyright (C) 2016 Garfileo.
# This file is distributed under the same license as the zero package.
#  <garfileo@gmail.com>, 2016.
#
msgid ""
msgstr ""
"Project-Id-Version: zero 0.1
"
"Report-Msgid-Bugs-To: garfileo@gmail.com
"
"POT-Creation-Date: 2016-01-17 17:24+0800
"
"PO-Revision-Date: 2016-01-17 17:26+0800
"
"Last-Translator:  <garfileo@gmail.com>
"
"Language-Team: Chinese (simplified)
"
"Language: zh_CN
"
"MIME-Version: 1.0
"
"Content-Type: text/plain; charset=UTF-8
"
"Content-Transfer-Encoding: 8bit
"

#: src/moon.c:12
msgid "Set <chunk> as the entrance for extracting code."
msgstr "將指定的程式碼塊設為程式碼的提取入口"

#: src/moon.c:14
msgid "place output into <file>."
msgstr "將提取到的程式碼輸出至指定的檔案"

#: src/moon.c:23
msgid "zero-file"
msgstr "道檔案"

#: src/moon.c:28
msgid "You should give me zero file!"
msgstr "你應當向我提供一份道檔案"

使用 msgfmt 命令可以檢查修改後的 zh_CN.po 是否合乎規範,即:

$ msgfmt --statistics --check zh_CN.po 
4 translated messages.

請認真對比 po/zh_CN.po 與 po/zero.pot 檔案,弄清楚我都改了那些內容。

在我確認 zh_CN.po 內容無誤後,我就將中文字地化語種『註冊』到 po/LINGUAS 檔案,即:

$ echo zh_CN >> LINGUAS

po/LINGUAS 是提供給 configure 指令碼使用的。由 configure 指令碼生成的 Makefile 會根據 LINGUAS 中的資訊找到 zh_CN.po 檔案,然後將它交給 msgfmt 工具處理成 zh_CN.gmo 檔案。這份 zh_CN.gmo 檔案就是 zh_CN.po 的二進位制版本。在 make install 階段,zh_CN.gmo 會被複制到 $PREFIX/share/locale/zh_CN/LC_MESSAGES 目錄,並被改名為 zero.mo!這一切都拜 po/LINGUAS 所賜。

這樣,moon 程式的本地化工作就完成了,剩下的事都交給 GNU Autotools 來做:

$ cd ../build
$ ../configure
$ make
$ cd po
$ make update-po
$ cd ..
$ sudo make install  # moon 預設會被安裝到 /usr/local/bin 目錄
                     # zh_CN.gmo 會被複製為 /usr/local/share/locale/zh_CN/LC_MESSAGES/zero.mo 檔案

$ which moon
/usr/local/bin/moon
$ ls /usr/local/share/locale/zh_CN/LC_MESSAGES
zero.mo

$ moon -h
Usage:
  moon [OPTION...] zero-file

Help Options:
  -h, --help                 Show help options

Application Options:
  -e, --entrance=<chunk>     Set <chunk> as the entrance for extracting code.
  -o, --output=<file>        place output into <file>.

$ LANG=zh_CN.UTF-8 moon -h
用法:
  moon [選項...] 道檔案

幫助選項:
  -h, --help                 顯示幫助選項

應用程式選項:
  -e, --entrance=<chunk>     將指定的程式碼塊設為程式碼的提取入口
  -o, --output=<file>        將提取到的程式碼輸出至指定的檔案

後記

這份文件寫了 2 天,上述總結的知識已經用在了我的專案中。我之所以不厭其煩的把它們寫出來,是因為四年前我曾經掌握了這些知識中的大部分,但是現在當我需要用它們的時候,我發現已經全忘記了。這樣記下來,以後可能就不會那麼容易忘記。

如果不通過 GNU Autotools + GNU gettext 來讓你的程式支援國際化與本地化,而是徒手使用 GNU gettext,可參考:https://fedoraproject.org/wiki/How_to_do_I18N_through_gettext

相關文章