用CMake自动化gettext多语言构建:从.pot到.mo一键完成

📅 发布时间:2026/10/21 22:51:36
用CMake自动化gettext多语言构建:从.pot到.mo一键完成
1. 项目概述1.1 为什么要操心“一键构建多语言程序”几个月前我把一个内部工具从纯命令行项目改造成了带界面、带菜单的图形程序。功能做完之后顺手把字符串硬编码全部抽出来接上了gettext这套国际化方案。结果发现一个问题每当翻译人员更新了.po文件或者新加的字符串忘记跑一遍xgettext构建出来的程序界面就会“中英混杂”有些菜单项是中文有些还是英文非常难看。后来我痛下决心把整个流程全部塞进CMake里——从扫描源文件生成.pot模板、合并.po翻译文件、到最终编译成.mo一气呵成。之后不管是CI流水线还是本机开发只需要执行一句命令完事。这篇文章不打算重复gettext的基础教程重点放在“如何用CMake把这些环节串起来”包括我踩过的坑、为什么这样写、以及每个关键函数背后的逻辑。1.2 这套方案能解决什么问题如果你经历过以下场景这篇文章正好适合你项目已经接入gettext但每次都要手动执行xgettext、msgmerge、msgfmt步骤繁琐且容易漏。.po文件在仓库里维护团队成员改了代码却忘了更新翻译模板导致新功能界面没有翻译。Windows下MSVC编译器和Linux下GCC混用gettext工具链的路径、参数行为不一致构建脚本写起来很痛苦。想在上传代码的时候自动打包语言包或让CI自动检查.po文件是否有语法错误。这套CMake方案解决的核心问题是把“代码里新增字符串”到“界面显示多语言”这条链路自动化。它不改变你的翻译工作流只是把执行翻译工具链的过程托管给CMake让编译系统成为唯一入口。2. 整体设计思路解析2.1 gettext工具链的完整链路要构建自动化必须先把gettext工具链的标准工作流吃透。正常的操作顺序是这样xgettext扫描源代码文件中的可翻译字符串通常是被_()、gettext()等宏包裹的生成一个.pot模板文件。这个文件不是给翻译人员直接编辑的它是一个“母版”。msginit基于.pot模板初始化一个特定语言的.po翻译文件。这一步只在首次添加语言时使用。msgmerge用更新后的.pot模板合并到已有的.po文件里。它会保留已有翻译同时把新增字符串标记出来让翻译人员知道哪些是新的。msgfmt把.po文本文件编译成二进制的.mo文件。程序运行时读取的是.mo而不是.po。每一步都有自己的输入输出依赖。手动执行的时候最怕的是顺序不对、参数漏掉、字符串路径用了相对路径导致各模块加载不到尤其项目变复杂后这些坑会被无限放大。2.2 用CMake脚本化关键环节我的第一版方案只是简单地在CMakeLists里写了几个add_custom_command把上面的工具链按顺序串起来。但实际使用时发现几个问题模板生成依赖源文件列表xgettext需要传源文件参数。每次新增源文件这个命令的依赖就要手动更新很烦。多语言并行项目里有中文、日文、法文等多套翻译时每个语言都要单独跑一遍msgmerge和msgfmt如果用add_custom_command写死循环生成逻辑会很笨拙。增量构建CMake的add_custom_command如果依赖处理不当会导致每次构建都重新编译.mo拖慢编译速度。所以第二版我做了重构写了一个可复用的CMake函数接收语言列表作为参数内部用foreach循环处理每个语言。源文件依赖通过target_sources和自定义的源文件列表变量动态获取避免手工维护。在设计时我还做了几个取舍。.pot模板文件我放在build目录而不是源码目录避免每次构建都污染代码仓库。.po文件则放在源码目录维护这样翻译人员改动后能在版本控制系统里清晰追踪。这些决策在后面第三节的代码里会体现。2.3 目录结构设计避免路径地狱多语言项目最难受的问题之一就是路径。不同模块、不同语言、不同构建类型一旦路径拼接错误出的问题非常隐蔽程序不报错但没有任何翻译输出。我的目录结构大致是这样project_root/ ├── CMakeLists.txt ├── cmake/ │ └── Gettext.cmake # 可复用的多语言构建函数 ├── src/ │ ├── main.c │ ├── ui.c │ └── ... ├── po/ │ ├── zh_CN.po # 中文翻译 │ ├── ja_JP.po # 日文翻译 │ └── POTFILES.in # 源文件列表xgettext用这里有三个关键设计决策POTFILES.in文件独立存放源文件列表。xgettext需要知道哪些文件包含待翻译字符串与其在CMake里硬编码不如单独维护一个文件生成.pot时直接传-f参数读这个文件。生成的.mo文件路径命名为{locale}/LC_MESSAGES/{domain}.mo。gettext运行时查找路径就是这个结构例如/usr/share/locale/zh_CN/LC_MESSAGES/myapp.mo。CMake函数接收的语言代码要和.po文件名、运行时locale路径严格一致。不要出现前面用zh_CN、后面却写成zh-CN这种不一致。3. 工具选型与原理细节3.1 gettext工具链的安装与版本差异在Linux上gettext工具链通常随系统自带包名一般是gettext。Debian/Ubuntu系安装命令sudo apt install gettextmacOS上通过Homebrew安装brew install gettextWindows的麻烦一点。MSYS2环境里可以装或者用预编译的Windows版本。但无论哪种方式工具的dispatcherxgettext、msgfmt等都必须能被CMake找到。所以我在CMake里用find_program逐个查找并给出友好提示。各工具版本间的差异主要集中在命令行参数上。老版本0.18.x和新版本0.20的xgettext参数细节有差异比如--keyword的参数个数规则、--from-code的默认值处理。我建议统一使用最新的稳定版我在项目中用的是0.21版本所有参数行为均基于此版本验证。3.2 PO文件格式关键点.po文件的格式表面上看是键值对但细节很多。一个最简示例msgid Hello msgstr 你好msgid是源码中出现的原文msgstr是翻译后的文本。翻译人员在编辑这个文件时可能会遇到msgid_plural复数形式、msgctxt消歧上下文等高级特性。如果你的项目涉及复数建议在xgettext提取时用--keyword显式声明函数签名中n参数的语义否则复数形式可能丢失。.po文件头部的元信息也很重要。Content-Type: text/plain; charsetUTF-8这一行如果缺失或编码错误在Windows下编译.mo时很容易出错。团队协作时建议在仓库里放一个标准的.po文件头模板避免每个翻译人员各自的编辑器设置不同搞出各种编码差异。3.3 MO文件与运行时查找逻辑.mo是二进制文件msgfmt把.po编译成.mo后程序运行时通过bindtextdomain和textdomain指定域domain和路径。我在测试时经常遇到的一个问题是忘记安装.mo文件到系统locale目录或程序运行时当前目录不对导致locale目录下的内容根本不会被加载。所以运行时要注意两点调用setlocale(LC_ALL, )前程序中要正确包含locale.h并且链接gettext相关库。用bindtextdomain指定的路径必须是实际存在的目录。CMake构建生成的.mo文件要么安装到系统目录要么在程序旁边建一个locale目录然后用相对路径绑定。#include locale.h #include libintl.h #define _(str) gettext(str) int main(void) { setlocale(LC_ALL, ); bindtextdomain(myapp, ./locale); textdomain(myapp); printf(_(Hello, world!\n)); return 0; }注意./locale是相对于程序当前工作目录的。如果你的程序通过systemd服务启动工作目录可能不是你预期的地方那就需要绝对路径或计算可执行文件所在目录这点很容易踩坑。4. 核心实操CMake一键构建配置详解4.1 编写可复用的Gettext.cmake模块CMake模块化的惯例是把函数封装在单独的文件中然后在主CMakeLists里include。我写了一个Gettext.cmake核心是一个名为add_gettext_translations的函数。# cmake/Gettext.cmake include(CMakeParseArguments) function(add_gettext_translations) set(options) set(oneValueArgs DOMAIN POT_FILE) set(multiValueArgs LOCALES SOURCES) cmake_parse_arguments( GETTEXT ${options} ${oneValueArgs} ${multiValueArgs} ${ARGN} ) if(NOT GETTEXT_DOMAIN) message(FATAL_ERROR add_gettext_translations: DOMAIN is required) endif() if(NOT GETTEXT_POT_FILE) message(FATAL_ERROR add_gettext_translations: POT_FILE is required) endif() # 默认只生成当前构建的 source 对应的 .pot set(_pot_file ${CMAKE_CURRENT_BINARY_DIR}/${GETTEXT_POT_FILE}) add_custom_command( OUTPUT ${_pot_file} COMMAND xgettext --languageC --keyword_ --keywordN_ --keywordQ_ --from-codeUTF-8 --output${_pot_file} --files-from${CMAKE_CURRENT_SOURCE_DIR}/po/POTFILES.in DEPENDS ${GETTEXT_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/po/POTFILES.in COMMENT Generating translation template ${_pot_file} VERBATIM ) set(_mo_outputs) foreach(locale IN LISTS GETTEXT_LOCALES) set(_po_file ${CMAKE_CURRENT_SOURCE_DIR}/po/${locale}.po) set(_output_dir ${CMAKE_CURRENT_BINARY_DIR}/locale/${locale}/LC_MESSAGES) set(_mo_file ${_output_dir}/${GETTEXT_DOMAIN}.mo) # 生成合并后的po供翻译人员更新 set(_merged_po ${CMAKE_CURRENT_BINARY_DIR}/po/${locale}.po) add_custom_command( OUTPUT ${_merged_po} COMMAND mkdir -p ${CMAKE_CURRENT_BINARY_DIR}/po COMMAND msgmerge --update --backupnone --previous ${_po_file} ${_pot_file} -o ${_merged_po} DEPENDS ${_pot_file} ${_po_file} COMMENT Merging translations for ${locale} VERBATIM ) # 编译成mo文件 add_custom_command( OUTPUT ${_mo_file} COMMAND mkdir -p ${_output_dir} COMMAND msgfmt --check --check-format --verbose ${_merged_po} -o ${_mo_file} DEPENDS ${_merged_po} COMMENT Compiling ${locale} translation VERBATIM ) list(APPEND _mo_outputs ${_mo_file}) add_custom_target(translate_${locale} ALL DEPENDS ${_mo_file}) endforeach() set(GETTEXT_MO_FILES ${_mo_outputs} PARENT_SCOPE) endfunction()这段代码解决了一个核心问题把.pot生成、.po合并、.mo编译串成一条依赖链。如果任何上游文件有变化后续步骤自动重跑。4.2 主CMakeLists集成下面是主CMakeLists.txt的配置。假设项目本身是一个名为myapp的可执行目标。cmake_minimum_required(VERSION 3.16) project(MyApp VERSION 1.0.0 LANGUAGES C) set(CMAKE_C_STANDARD 11) find_package(Gettext REQUIRED) include(cmake/Gettext.cmake) set(MYAPP_SOURCES src/main.c src/ui.c ) add_executable(myapp ${MYAPP_SOURCES}) target_link_libraries(myapp PRIVATE gettext) # 提取可翻译字符串的源文件列表 set(TRANSLATION_SOURCES ${MYAPP_SOURCES}) # 调用封装函数生成.mo文件 add_gettext_translations( DOMAIN myapp POT_FILE myapp.pot LOCALES zh_CN ja_JP SOURCES ${TRANSLATION_SOURCES} )find_package(Gettext)是CMake内置的模块它会尝试寻找xgettext、msgmerge等工具并定义对应变量。如果找不到会给出一个明确的错误信息。在Windows上如果装了MSYS2但环境变量没配置好这里就会失败。4.3 手动更新.po的标准流程虽然构建是自动的但翻译文件的维护还是需要手工介入。我的日常习惯是代码改动后跑一次构建。CMake会发现源文件更新自动重新生成.pot并调用msgmerge。合并后的.po文件在build/po/目录里可以直接从那里拿给翻译人员或者在确认没问题后替换源码仓库里的.po文件。第一次添加新语言时需要用msginit初始化文件。比如添加韩语msginit --localeko_KR --inputbuild/po/myapp.pot --outputpo/ko_KR.po初始化后把文件纳入版本控制以后它就成为该语言的主翻译文件。4.4 安装规则与运行时路径为了让程序实际加载到生成的.mo文件还需要把.mo安装到标准目录或同步到程序运行目录。install( DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/locale/ DESTINATION ${CMAKE_INSTALL_LOCALEDIR} FILES_MATCHING PATTERN *.mo )CMAKE_INSTALL_LOCALEDIR在GNUInstallDirs模块里定义标准的Linux路径是share/locale。Windows上可以安装到bin/locale或程序运行目录。5. 实操细节常用参数的完整解读5.1 xgettext参数选型xgettext命令行参数往往晦涩难记我在封装成函数后把关注的参数整理成一张速查表。参数作用我的建议--languageC指定源码语言类型指定源语言C项目用CC用CPython用Python避免自动推断出错--keyword_声明哪些宏/函数调用被视为翻译点项目用_()宏必须在这里声明如果是gettext()可以省略因为它是默认关键字--keywordN_标记“延迟翻译”的字符串适用于静态初始化列表中的字符串--from-codeUTF-8指定源码文件编码强烈建议固定UTF-8避免跨平台编码错乱--files-fromPOTFILES从文件列表读取要扫描的文件减少命令行长度且方便维护--omit-header生成.pot时不输出头部信息一般在合并到.po时才需要用很少直接用5.2 msgmerge的行为控制msgmerge负责合并旧.po和新的.pot。最常用的参数是--update它意味着旧文件被原地更新但我在CMake函数里加了一个-o参数输出到构建目录没有直接改源码库里的文件。这样做的考量是构建系统不应该悄悄修改源码目录下的文件一旦构建和手动改文件同时发生版本控制的冲突会很混乱。--backupnone表示不生成备份文件。默认情况下msgmerge可能会生成.po~等备份在构建目录里无所谓但如果直接对源码目录操作这些备份文件会被git误跟踪所以我在函数里强制指定不备份。--previous参数会在.po文件的注释里记录“旧字符串”信息。这对翻译人员极其友好因为当一个原文被修改时他们能在翻译工具里直观地看到“原先是怎么翻译的”。我强烈建议保留。5.3 msgfmt检查选项msgfmt --check会对.po文件做基本检查--check-format则会验证像printf格式占位符是否匹配。比如中文翻译里不小心把%d写成了%s--check-format会直接报错并终止构建。这能避免发布一个运行时报格式错误的版本。还有更严格的--check-accelerators用于菜单快捷键检查如File。如果你的程序有图形界面建议开启。我一般在CMake函数里默认加上--check和--check-format快捷键检查因为项目当时没有GUI就没有加。6. 常见问题与排查实录6.1 xgettext未找到源文件或生成空模板POTFILES.in文件里写的路径必须和CMake当前工作目录下的相对路径保持一致。如果你的.pot文件生成出来是空的只有header信息十有八九是POTFILES.in里的路径不对或者文件路径是相对于build目录而不是源码目录的。排查方法是打开POTFILES.in逐行检查。假设你的源码在src/下那么POTFILES.in里面应该写src/main.c而不是main.c。xgettext的路径解析是相对于当前进程的工作目录的CMake自定义命令默认工作目录是CMAKE_CURRENT_BINARY_DIR这里极其容易搞混。我最后的解决方式是在add_custom_command里显式指定工作目录WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}这样POTFILES.in里的路径就和Git仓库里的路径保持一致其他开发者克隆后也不会因为构建目录不同而出现问题。6.2 Windows下路径分隔符与shell命令的坑Windows环境中自定义命令里直接用mkdir -p在MSVC生成器下会失效因为默认shell是cmd不是bash。我早期踩这个坑时构建日志疯狂报错后面才意识到需要区分shell。解决方案是改用CMake的file(MAKE_DIRECTORY ...)来创建目录而不是依赖mkdir命令add_custom_command( OUTPUT ${_mo_file} COMMAND ${CMAKE_COMMAND} -E make_directory ${_output_dir} COMMAND msgfmt --check --check-format ${_po_file} -o ${_mo_file} DEPENDS ${_po_file} COMMENT Compiling ${locale} translation )${CMAKE_COMMAND} -E make_directory是跨平台的在Windows和Linux下行为一致。同理如果需要拷贝文件用cmake -E copy而不是cp。另外msgfmt等程序在Windows上如果不在PATH中find_program能帮你找到路径但必须注意CMake缓存变量。首次Configure时如果工具还没安装CMake会缓存“找不到”的结果之后即使装了工具也要删除CMakeCache.txt重新配置这点经常被人忽略。6.3 MSVC编译器的运行时库与gettext符号冲突如果你的项目用MSVC编译链接gettext库时会遇到一个麻烦Windows的gettext运行时库和MSVC的运行时库可能不匹配导致链接错误。一个可行的方案是使用libintl的预编译版本并确保编译选项/MD或/MT一致。我的个人建议是Windows上尽量使用MinGW工具链或者直接用MSYS2的环境来编译整个项目这样gettext库的兼容性问题最少。如果你非要坚持MSVC需要下载针对MSVC编译好的libintl库并且把#include libintl.h的路径和库路径都显式告诉CMakefind_path(LIBINTL_INCLUDE_DIR libintl.h) find_library(LIBINTL_LIBRARY NAMES intl) target_include_directories(myapp PRIVATE ${LIBINTL_INCLUDE_DIR}) target_link_libraries(myapp PRIVATE ${LIBINTL_LIBRARY})但这里还有个坑如果直接调用gettext函数MSVC下可能遇到重名冲突比如Windows自己的某些API也叫这个名字。稳妥的做法是定义宏重命名#define gettext libintl_gettext #include libintl.h这个方案在好几个开源项目里都见过实际验证下来能有效避免符号冲突。6.4 .mo文件更新了但程序显示还是英文这是个经典问题。排除代码逻辑没触发textdomain之外最可能的原因就是运行时找不到.mo文件。我排查这个问题时用了一个小技巧在程序里加一个调试开关启动时打印当前locale路径和期望的mo路径if (getenv(MYAPP_DEBUG_LOCALE)) { fprintf(stderr, bindtextdomain path: %s\n, bindtextdomain(myapp, ./locale)); fprintf(stderr, locale dir: %s\n, bindtextdomain(myapp, NULL)); }然后把生成的.mo文件和这个路径对比。如果路径下确实有.mo但依然显示原文再用msgunfmt命令反向检查msgunfmt locale/zh_CN/LC_MESSAGES/myapp.mo这个命令能把.mo文件里的内容和.po对照看是否确实是编译时的最新版本。有时候构建系统生成了.mo但安装路径还是旧的这个命令能一步定位。6.5 PO文件头部日期冲突导致重复构建几个月前我修一个奇怪的问题明明什么都没改每次构建都要重新编译.mo文件耗时很长。后来发现是msgmerge每次都会在合并后的.po文件头部更新PO-Revision-Date字段导致文件哈希每次都变CMake认为文件更新了就触发后续编译。这个问题的解决方式有两种。要么把生成.po的时间戳固定住比如用--no-location或自定义模板。要么干脆在CMake函数里把msgmerge生成的合并文件标记为“无需构建的主目标”——它只是一个中间文件只要翻译内容没变化就不应该触发.mo重编译。不过msgmerge更新日期字段本身说明它有变化严格来说这不算“错误”只是效率问题。我的变通方案是在函数末尾用cmake -E copy_if_different把合并后的.po拷贝到源码目录但如果日期变化导致文件内容不同copy_if_different还是会拷贝。所以更实际的方案是接受这种性能开销或者定期手动整理.po文件头。如果对构建性能极其敏感可以生成.pot后用脚本剔除日期行再比较——但这个复杂度已经超过多数项目的实际需求了。6.6 获取.po文件的翻译状态在CI流程中经常需要报告翻译进度哪些字符串已翻译、哪些过时、哪些缺失。msgfmt --statistics能给你输出统计信息msgfmt --statistics -o /dev/null po/zh_CN.po输出示例232 translated messages, 5 fuzzy translations, 12 untranslated messages.但--statistics在项目使用--check时也会输出不过它默认不显示每个文件的详细情况。如果你想更深入地分析可以用msgattribmsgattrib --untranslated po/zh_CN.po我查过这是查看未翻译字符串最直接的工具。在CI脚本里把这个命令的调用接入CMake自定义目标add_custom_target(check_translations COMMAND msgfmt --statistics --check -o /dev/null ${CMAKE_CURRENT_SOURCE_DIR}/po/zh_CN.po COMMENT Checking Chinese translation stats )add_custom_target和add_custom_command的区别这里体现得很清楚add_custom_target默认不会被编译依赖链触发只有你显式执行cmake --build . --target check_translations时才会运行。这正好适合做CI的辅助检查。7. 常见问题速查表现象可能原因解决方法.pot文件内容为空POTFILES.in里路径错误在add_custom_command中添加WORKING_DIRECTORY为源码目录Windows下构建报mkdir不是命令用的是cmd shell而非bash改用${CMAKE_COMMAND} -E make_directory链接时gettext符号冲突MSVC环境与libintl库不匹配用#define gettext libintl_gettext重命名程序运行时无翻译输出locale路径绑错打印bindtextdomain返回值比对实际路径.mo文件内容过旧安装规则漏配或安装路径错误用msgunfmt反查文件内容每次构建都重编译.momsgmerge更新日期字段把.po依赖于模板的依赖链改用copy_if_different找不到xgettext命令gettext工具链未安装apt install gettext或brew install gettext然后重新configure8. 实操心得总结做这套自动化改造最花功夫的地方不是写CMake函数本身而是把gettext工具链在每个平台上的行为差异摸透。我刚开始也想省事在构建脚本里拼接字符串去调用命令但Windows下的路径空格、反斜杠问题马上就让我改了主意。现在用CMake的VERBATIM和COMMAND参数路径处理交给CMake去转义干净利落省掉了大量跨平台兼容代码。另一个深刻的体会是把.po的合并过程放进了构建系统之后代码仓库里.po文件的更新节奏变得可控了。以前团队成员经常忘记把新字符串加到翻译文件里现在只要跑一次构建合并后的.po自动生成翻译人员对照着就能看到新增内容。虽然这不能替代人工翻译但至少“漏掉某条字符串没翻译”这种事故明显减少了。最后再分享一个小技巧如果你在折腾过程中发现某些陌生工具记不住用法可以直接在CMake函数里加一个“帮助目标”add_custom_target(help_gettext COMMAND ${CMAKE_COMMAND} -E echo Run: cmake --build . --target translate_zh_CN # rebuild only Chinese mo file COMMENT Help for gettext translation targets )这个帮助目标不会影响主构建但在团队协作时其他人不需要去翻文档就能知道怎么单独刷新某个语言的翻译文件。实际使用下来这种“平价但实用”的小改进比一个复杂的图形界面或者大型工具链更容易被人接受。