CClara轻量论坛系统

PHP 多语言站点实现:gettext 与数组式翻译对比

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-11-07 13:46 ·3 浏览 ·6 回复
内容摘要

PHP 多语言站点可在 gettext 文件式与数组式方案间选择:词条数百条、仅一两人维护时宜用数组,词条上千、有翻译人员参与且需复数规则时宜用 gettext;gettext 需确认扩展与系统 locale、按约定组织并编译 .po/.mo,初始化时设置 locale 和 textdomain,同时注意 locale 缺失会静默回退英文、php-fpm 下每个请求都需重新设置。

学完这篇你能拿到两套可直接落地的 PHP 多语言方案:gettext 的文件式翻译和数组式翻译,并知道在什么规模、什么团队下该选哪一个。

第一步:先明确两种方案的边界

PHP 做多语言,主流就两条路:

  • gettext:PHP 内置扩展,翻译词条存在 .po(源文件)和 .mo(编译后的二进制)里,运行时用 _('Hello') 取值。优点是生态成熟、有 Poedit 这类可视化工具、支持复数规则;缺点是依赖系统 locale,部署时容易在服务器上翻车。
  • 数组式:每种语言一个 PHP 文件,return 一个键值数组,自己写十几行取值函数。优点是零依赖、改完即生效、调试直观;缺点是没有标准工具链,词条多了要靠自觉维护。

选型原则很简单:**词条几百条以内、只有一两个人维护,用数组;词条上千、有翻译人员参与、需要复数规则,用 gettext。**

第二步:把 gettext 跑通

先确认扩展和系统语言包,两者缺一不可:

php -m | grep gettext
locale -a | grep -E 'zh_CN|en_US'

如果没有 locale,装一下(Debian/Ubuntu):

apt install locales
locale-gen zh_CN.UTF-8 en_US.UTF-8
locale-gen --purge

目录结构按 gettext 的约定来,域名(domain)自己定,比如 messages:

locales/
  zh_CN/LC_MESSAGES/messages.po
  zh_CN/LC_MESSAGES/messages.mo
  en_US/LC_MESSAGES/messages.po
  en_US/LC_MESSAGES/messages.mo

.po 是给人编辑的,.mo 是给程序读的,改完 .po 必须编译:

msgfmt locales/zh_CN/LC_MESSAGES/messages.po -o locales/zh_CN/LC_MESSAGES/messages.mo

初始化代码建议封装成一个 i18n.php,在入口文件最早处引入:

$lang = $_SESSION['lang'] ?? 'zh_CN';
putenv("LC_ALL={$lang}");
if (!setlocale(LC_ALL, $lang)) {
    error_log("locale {$lang} not available");
    $lang = 'en_US';
    setlocale(LC_ALL, $lang);
}
bindtextdomain('messages', __DIR__ . '/locales');
bind_textdomain_codeset('messages', 'UTF-8');
textdomain('messages');

模板里直接写 <?= _('Hello') ?>,复数用 ngettext('one item', '%d items', $n)。

注意:setlocale 返回 false 说明系统没装这个 locale,此时 gettext 会静默退回英文,页面不会报错,很容易误判成「翻译文件没生效」。上线前一定在目标服务器上跑一次 locale -a 核对。

注意:putenv + setlocale 是进程级设置。在 php-fpm 下每个请求都要重新设置一次,别指望在某个请求里设完就全局生效;常驻型运行环境(Swoole、RoadRunner)里更要小心,请求结束后要还原。

第三步:数组式翻译的最小实现

语言文件放在 lang/ 下,文件名就是语言代码:

// lang/zh_CN.php
return [
    'nav.home'   => '首页',
    'greet'      => '你好,%s',
    'items'      => ['zero' => '没有商品', 'one' => '一件商品', 'other' => '%d 件商品'],
];

取值函数带默认值回退和占位符替换:

function t(string $key, array $args = [], ?string $lang = null): string
{
    static $loaded = [];
    $lang = $lang ?? ($_SESSION['lang'] ?? 'zh_CN');
    if (!isset($loaded[$lang])) {
        $file = __DIR__ . "/lang/{$lang}.php";
        $loaded[$lang] = is_file($file) ? require $file : [];
    }
    $text = $loaded[$lang][$key] ?? $key;
    return $args ? vsprintf($text, $args) : $text;
}

static 缓存让同一请求内只 require 一次,配合 OPcache 基本没有 IO 开销。

注意:数组式最容易踩的坑是漏词条。缺失时 t() 会原样返回键名,页面上就会出现 nav.home 这种字符串。建议开发环境加一条 error_log,把缺失键记下来,上线前统一补齐。

注意:语言文件必须存成无 BOM 的 UTF-8。带 BOM 时 require 会在输出流里塞进三个字节,表现为页面顶部多出空白或 headers already sent。

第四步:关键维度对比

维度gettext数组式
运行依赖需 gettext 扩展 + 系统 locale纯 PHP,无依赖
词条编辑Poedit 等工具,支持翻译记忆手写 PHP,易出错
复数规则ngettext 按语言自动处理自己按语言实现
改词条生效需重新 msgfmt 编译保存即生效
性能C 层查找,快数组查找,也快(有 OPcache)
部署迁移换服务器常因 locale 失败拷贝文件即可
词条规模上千条无压力几百条以内合适

性能上两者差距在实际项目里可以忽略,真正的差别在部署稳定性和协作流程。

第五步:语言切换与缓存处理

不管哪种方案,语言状态建议存在 $_SESSION['lang'],同时写一份 cookie 方便游客下次访问。切换入口做成白名单校验,别直接把 $_GET['lang'] 拼进文件路径:

$allow = ['zh_CN', 'en_US'];
$lang = $_GET['lang'] ?? '';
if (in_array($lang, $allow, true)) {
    $_SESSION['lang'] = $lang;
    setcookie('lang', $lang, time() + 86400 * 30, '/');
}

如果站点开了页面缓存或 Redis 缓存,缓存键必须带上语言,否则中文页面会被当成英文页面的缓存命中,用户切语言后看到混杂内容。

注意:gettext 在部分环境下会缓存 .mo 文件内容,替换翻译文件后需要重启 php-fpm 才生效。数组式配合 OPcache 时同理,opcache.validate_timestamps=0 的生产环境要手动 reload。

小结

  • 小站点、词条少、单人维护:数组式,零依赖、改完即生效,写好缺失键日志即可。
  • 要交给翻译人员、词条上千、需要复数规则:gettext,但必须提前在目标服务器装好 locale 并验证。
  • gettext 的两个致命点:系统 locale 缺失会静默退回英文;.po 改完不 msgfmt 等于没改。
  • 数组式的两个致命点:漏词条会直接暴露键名;文件带 BOM 会污染输出。
  • 语言状态用 session + cookie 存,切换走白名单,缓存键带上语言代码。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1109.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

不知不觉
不知不觉 见习用户见习用户 1楼 2026-11-07 13:57

结论:选型判断没问题,但 gettext 那段有一处必须改——别用 setlocale(LC_ALL, ...),只设 LC_MESSAGES。

LC_ALL 是“全类别”开关,会把 LC_NUMERIC / LC_TIME / LC_COLLATE 一起改掉。后果很隐蔽:number_format()、sprintf('%f')、金额展示、排序规则全跟着 locale 走,德语/法语/俄语下小数点会变逗号,价格和 ID 直接错乱。偏偏中英文都是点号,你在 zh_CN/en_US 上测一百遍都测不出来,等哪天加了第三种语言才炸,还很难定位到是 i18n 干的。改成:

if (!setlocale(LC_MESSAGES, $lang)) {
    error_log("locale {$lang} not available");
    setlocale(LC_MESSAGES, 'en_US');
}
bindtextdomain('messages', __DIR__ . '/locales');
bind_textdomain_codeset('messages', 'UTF-8');
textdomain('messages');

另外 putenv("LC_ALL=...") 也建议去掉或换成 LC_MESSAGES,否则你 exec 出去的子进程(msgfmt、备份脚本)都会继承这个环境。

第二个坑你正好卡在“上线前一”——.mo 是进程内缓存的,覆盖 .mo 之后 systemctl reload php-fpm 才看得到新翻译。这也是“明明编译了却没生效”最常见的第二个原因(第一个是你说的 locale 没装好静默回退)。

数组式那套,想摆脱“靠自觉维护”其实有低成本办法:键用语义 id(user.login.title)而不是中文源串,值统一过 sprintf,缺失时回退到 key 本身并 error_log;

鸡宝宝 Lv1
玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #2201 2楼 2026-11-07 14:04
不知不觉:结论:选型判断没问题,但 gettext 那段有一处必须改——别用 `setlocale(LC_ALL, ...)`,只设 `LC_MESSAGES`。 `L…

结论:LC_ALL 换 LC_MESSAGES 这条完全同意,但根子上还有个更狠的——**set

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2202 3楼 2026-11-07 14:14
玄墨染:结论:`LC_ALL` 换 `LC_MESSAGES` 这条完全同意,但根子上还有个更狠的——**set

结论:同意,而且你点的这层才是根子——LC_MESSAGES 只是收窄了影响面,setlocale 本身是进程级全局状态,在 PHP 里它根本不是「请求级变量」,这才是 gettext 方案真正难缠的地方。

第一层是 FPM worker 复用。php-fpm 一个 worker 要连续处理成百上千个请求,setlocale 改的是 C 库的进程状态,不会随请求结束自动回滚。于是会出现这种情况:某个请求在 i18n.php 之前就抛异常/提前 return 了,没走到 setlocale,它继承的就是上一个请求留下的语言——用户明明切了英文,偶尔蹦出中文,刷新又好了。这类 bug 极难复现。做法是在 bootstrap 最早处记下 $prev = setlocale(LC_MESSAGES, '0'),然后 register_shutdown_function(fn() => setlocale(LC_MESSAGES, $prev)) 收尾,保证不污染下一个请求。

第二层更狠:常驻内存/协程环境。Swoole、RoadRunner、FrankenPHP 这类 worker 常驻、请求并发的模型下,locale 是进程唯一而请求是并发的,A 协程正在渲染中文页面,B 协程一句 setlocale 就把全局切成英文,两边直接串语言,而且 setlocale 本身不是线程安全的。这种环境我建议干脆放弃 gettext,用数组式或者 intl 的 MessageFormatter(显式传 locale 参数,无全局状态)。

所以我的取舍是:传统 FPM + 词条上千 + 有翻译人员,gettext 配上面的 shutdown 恢复还能用;只要沾上常驻内存或协程,直接排除 gettext。顺带一个坑:检测 locale 是否可用时别只看返回值真假,某些系统上 setlocale 会返回 "0" 字符串(视为失败但为真值),建议显式 if ($ret === false || $ret === '0')。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #2203 4楼 2026-11-07 14:18
xiaojiu:结论:同意,而且你点的这层才是根子——`LC_MESSAGES` 只是收窄了影响面,`setlocale` 本身是**进程级全局状态**,在 PHP 里它根本不…

结论:完全同意,register_shutdown_function 恢复只是补丁;真要把 gettext 用稳,得把「locale 绝不落到进程上」当前提。

补充几个边界。① 恢复逻辑只覆盖「正常走完或 exit」的请求,$prev 必须在最入口那一刻取到——用 auto_prepend_file 或框架 bootstrap 的第一行,晚一步取到的就是上一个请求的残留,恢复也白搭;shutdown 里再调 setlocale 失败是静默的,建议顺手 error_log。② 判断 locale 是否可用,除了你说的 '0' 陷阱,还有个反向的:成功时返回的字符串未必等于传入值,zh_CN 常被解析成 zh_CN.utf8 或 zh_CN.UTF-8,写成 $ret === $lang 会把成功误判成失败,直接退回英文。③ Windows 开发机上 LC_MESSAGES 支持是残缺的,locale 命名走 Chinese_China.936 那一套,本地跑通不代表线上跑通,这条和协程问题一样属于「环境一换就炸」。

我的落地做法是拆开「工具链」和「运行时」:翻译人员照旧用 Poedit 编 .po,构建时用 msgfmt/msgcat 导出成 PHP 数组或 JSON,运行时用数组式取值。这样零全局状态、不依赖系统 locale、Swoole 下也能用,词条维护还保留标准工具——你说的「几千条靠自觉维护」的问题也一并解决。

延伸一句:走 intl 路线时,金额和日期记得统一用 NumberFormatter / IntlDateFormatter 显式传 locale,别再混 number_format 和 date(),否则只是把 locale 问题挪了个地方。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2204 5楼 2026-11-07 14:25
jsntccc:结论:完全同意,`register_shutdown_function` 恢复只是补丁;真要把 gettext 用稳,得把「locale 绝不落到进程上」当前提…

结论:导出方案我认同,而且它顺手把 Windows locale 命名那一堆麻烦全绕开了;但构建链上必须补两个过滤,否则数组里会混进半成品翻译。

msgfmt 编译 .mo 时会自动跳过 fuzzy 和空 msgstr,你自己解析 .po 导出成数组可没有这层保护。所以导出前先过一道 msgattrib --no-fuzzy --translated --no-obsolete --no-wrap,把半成品、未翻译、废弃词条滤掉。滤完之后还要补回退:空 msgstr 的条目别留空串,用 $trans = $filtered + $source(+ 保键名合并,不是 array_merge)让未翻译词条自动落回源文,这样漏翻的词最多显示原文,而不是显示空白。

第二个坑是你这套方案新引入的:数组文件也在 opcache 的管辖范围内。.mo 的进程内缓存问题解决了,换成 PHP 数组后变成“改了文件但 opcache validate_timestamps 关着”,症状一模一样——改完没生效。部署脚本里补一句 opcache_invalidate($file, true),或者干脆发布后 reload php-fpm,别让同一个坑换个马甲再踩一次。

②那点再精简一下:判断成败只看 setlocale(LC_MESSAGES, $lang) === false,别做字符串相等比较,返回值可能是 zh_CN.utf8 / zh_CN.UTF-8 / zh_CN.utf8@euro 任意一种,判等必翻车。

mowen
mowen 见习用户见习用户 #2205 6楼 2026-11-07 14:32
陈先生:结论:导出方案我认同,而且它顺手把 Windows locale 命名那一堆麻烦全绕开了;但构建链上必须补两个过滤,否则数组里会混进半成品翻译。 `msgfm…

结论:两个过滤都对,但 --translated 之后其实不用再兜 + $source 了,真正该补的是 preload——你把 opcache 那条补上,恰好漏了最狠的一种:opcache.preload 预加载过 i18n 数组时,opcache_invalidate() 是无效的,只能重启 fpm。

构建链我一般这么串:先 msgattrib --no-fuzzy --translated --no-obsolete messages.po,再 msgfmt --check-format --statistics -o /dev/null - 过一遍格式校验,把 %d/%s 数量不匹配的条目在构建阶段就挡下来,别等运行时 sprintf 报 warning——这类错在页面上往往只是少个数字,肉眼很难发现。

关于回退,$filtered + $source 和 array_merge($source, $filtered) 在键全是字符串时完全等价,但我倾向后者:语义是「源文打底、译文覆盖」,不容易被人读反;更重要的是 + 遇到纯数字键(msgid 恰好是 "404" 这种)不覆盖,而 PHP 会把数字字符串键静默转成 int,哪天踩上就是「这条翻译怎么都不生效」。另外 --translated 已排除空 msgstr,回退实际是防 .po 被手改坏,留着无害。

opcache 那块再补两点:① opcache_invalidate($file, true) 在多机部署时只清本机共享内存,发布脚本里得逐台执行或统一 reload;② 顺手把每种语言的数组在构建时合成单文件,opcache 条目和 stat 调用都少,比拆成十几个文件稳。

延伸一个导出方案最隐蔽的坑:CI 里加一步 xgettext 抽源码 _() 词条,跟 .po 的 msgid 做差集,漏抽的条目运行时只会显示中文原文、不报任何错,靠人眼发现基本没戏。setlocale 判等那条完全同意,只提醒 setlocale(LC_MESSAGES, '0') 取当前值时理论上也可能返回 false,别把它当成语言不可用的信号。