如何写一个 GNU 风格的命令行程序

c/c++

浏览数:214

2019-3-30

AD:资源代下载服务

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\n", zero_entrance);
        g_print("%s\n", zero_output);
        g_print("%s\n", 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\n", zero_entrance);
g_print("%s\n", zero_output);
g_print("%s\n", 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 文件复制到项目源码目录。你需要敲一下回车键,告诉 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\n", zero_entrance);
        g_print("%s\n", zero_output);
        g_print("%s\n", 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\n", zero_entrance);
        g_print("%s\n", zero_output);
        g_print("%s\n", 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\n", zero_entrance);
        g_print("%s\n", zero_output);
        g_print("%s\n", 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\n"
"Report-Msgid-Bugs-To: garfileo@gmail.com\n"
"POT-Creation-Date: 2016-01-17 16:20+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"

#: 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\n"
"Report-Msgid-Bugs-To: garfileo@gmail.com\n"
"POT-Creation-Date: 2016-01-17 17:24+0800\n"
"PO-Revision-Date: 2016-01-17 17:26+0800\n"
"Last-Translator:  <garfileo@gmail.com>\n"
"Language-Team: Chinese (simplified)\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"

#: 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