☰
PHP: The Right Way 国际化与本地化实战:基于 Gettext 的 PHP 多语言应用完整指南
2026/9/25 5:06:16 网站建设 项目流程
  • 文档
  • 教程

【免费下载链接】php-the-right-way

An easy-to-read, quick reference for PHP best practices, accepted coding standards, and links to authoritative tutorials around the Web

项目地址:https://gitcode.com/gh_mirrors/ph/php-the-right-way
点击查看免费下载

国际化(Internationalization,简称 i18n)与本地化(Localization,简称 l10n)是 PHP 应用走向多语言、多区域市场的必经之路。本文以 PHP: The Right Way 开源仓库中的 Internationalization and Localization 章节 为核心骨架,系统讲解从概念辨析、工具选型、Gettext 安装配置,到 PO/MO 文件体系、复数规则、Poedit 工作流与常见坑点的完整链路,并结合仓库内 UTF-8 处理、数据过滤 等相邻章节补充源码级佐证,让读者读完即可在自己的 PHP 项目中落地一套可维护的 i18n/l10n 方案。

先厘清三个核心概念:i18n、l10n 与复数规则

新手提示:i18n 和 l10n 都是 numeronym(数字缩写词)——用数字代替单词中省略的字母:internationalization 缩写为 i18n(首尾字母 i、n 之间有 18 个字母),localization 缩写为 l10n(首尾字母 l、n 之间有 10 个字母)。

要正确实施国际化,必须先区分两个相似概念以及一个常被忽视的关联概念:

  • 国际化(Internationalization):指把代码组织成可以适应不同语言或区域而无需重构的形式。这项工作通常只做一次——最好在项目启动之初就完成,否则后期可能需要对源码进行大规模修改。
  • 本地化(Localization):在既有 i18n 成果的基础上,主要通过翻译内容来适配界面。它通常在每次需要支持新语言或新区域时执行;每当界面新增部件,都需要为所有已支持的语言同步更新。
  • 复数规则(Pluralization):定义不同语言之间如何将「包含数字与计数器的字符串」互相衔接。例如英语中只有单数和复数两种形态(多数单词加 S 即构成复数);而俄语、塞尔维亚语在单数之外还有两种复数形式;斯洛文尼亚语、爱尔兰语、阿拉伯语甚至存在四、五、六种形式。复数规则直接决定翻译文件需要为同一句子准备多少个翻译版本。

常见的实现方式:从数组文件到 Gettext

数组文件方案:简单但不可扩展

国际化 PHP 软件最容易的方式是使用数组文件,并在模板中直接引用这些字符串,例如:

<h1><?=$TRANS['title_about_page']?></h1>

但这种做法很难推荐给正经项目,因为随着项目增长会暴露出一系列维护问题——有些问题在初期就会显现,比如复数规则的处理。所以,如果你的项目会超过几个页面,请不要再尝试这种方案。

其他 i18n 库与框架方案概览

除了 PHP 核心自带的 Gettext 实现,社区还有一些常见库,有的支持 Gettext 之外的其他 i18n 文件格式,有的提供额外特性。原文将其归纳如下:

库 / 框架格式支持特点提取器
aura/intl数组格式消息按 locale 分包的消息翻译;依赖intl扩展提供高级消息格式化(含复数消息)无
php-gettext/Gettext.po/.mo及多种格式面向对象接口;强大的多格式提取器(含gettext命令原生不支持的格式);可导出到其他格式以对接 JS 界面等系统部件有
symfony/translation多种格式(推荐 XLIFF)内部用strtr()实现占位符;不提供辅助函数与内置提取器无
laminas/laminas-i18n数组、INI、Gettext内置缓存层避免每次读取文件系统;含视图辅助函数、locale 感知的输入过滤器与验证器无
Laravel(框架内置)基本数组文件提供模板辅助函数@lang无自动提取器
Yii(框架内置)数组、Gettext、数据库基于 PHP 5.3 起可用的Intl扩展(依托 ICU 项目),支持数字拼写、日期/时间/区间/货币/序数的高级格式化有

一个重要的实战建议:如果你选用的库没有提取器,请坚持使用 gettext 文件格式,这样你仍然可以借用原版 gettext 工具链(包括 Poedit)完成字符串提取与翻译,也就是本文后续描述的那套工作流。

Gettext:历史、安装与启用

为什么是 Gettext

最经典、也常被业界当作 i18n/l10n 参考标杆的实现,是 Unix 工具Gettext。它诞生于 1995 年,至今仍是软件翻译的完整实现——既足够简单、容易跑起来,又拥有强大的配套工具。本章节即围绕 Gettext 展开,并会介绍一个图形化应用(Poedit),帮助你不必在命令行中手忙脚乱地维护 l10n 源文件。

安装与启用扩展

你需要通过包管理器安装 Gettext 及对应的 PHP 库,例如apt-get或yum。安装完成后,在php.ini中启用扩展:

  • Linux/Unix:extension=gettext.so
  • Windows:extension=php_gettext.dll

此外,本章节使用Poedit来创建翻译文件。它通常出现在系统包管理器中,支持 Unix、macOS 和 Windows 全平台,也可从其官网免费下载。

Gettext 文件体系:POT / PO / MO 三种文件

使用 Gettext 通常需要处理三种文件:

文件类型全称作用
POPortable Object(可移植对象)可读的「已翻译对象」列表,即给人阅读和编辑的翻译源文件
MOMachine Object(机器对象)对应的二进制文件,由 Gettext 在执行本地化时解释使用
POTTemplate(模板文件)只包含源码中所有现存键,作为生成和更新各 PO 文件的指南

几点关键规则:

  • POT 并非强制:取决于你使用的 l10n 工具,只保留 PO/MO 文件也完全可以;
  • 每种语言和区域对应一对 PO/MO 文件,而每个**域(domain)**只有一个 POT。

域(Domains):为同一词语的不同语义分家

在大型项目中,可能存在「同一个词在不同上下文传递不同含义」的情况,此时需要把翻译拆分成不同的域(domain)。域本质上就是一组有名字的 POT/PO/MO 文件,文件名即为该翻译域的名字。例如在 Symfony 项目中,域被用来隔离「校验错误消息」的翻译。

小型和中型项目为了简单起见通常只用一个域,域的名字可以任意取——本文示例统一使用"main"。

区域代码(Locale code):ISO 标准与方言

Locale 是标识某一语言版本的代码,遵循ISO 639-1(语言)与ISO 3166-1 alpha-2(国家/地区)规范:两个小写字母表示语言,可选地加下划线和两个大写字母表示国家或区域代码;少数稀有语言使用三个字母。

对某些使用者来说,国家部分看似冗余,但实际上很有必要——同一语言在不同国家存在方言,例如奥地利德语(de_AT)、巴西葡萄牙语(pt_BR)。当国家部分缺失时,该 locale 被视为该语言的「通用/混合」版本。

目录结构规范:Gettext 约定的落盘方式

使用 Gettext 必须遵循特定的目录结构。首先在源码仓库中选定一个任意的 l10n 文件根目录;在其内部,为每个需要的 locale 建一个文件夹,每个文件夹里再放一个固定的LC_MESSAGES目录,用于存放所有 PO/MO 文件对:

<project root> ├─ src/ ├─ templates/ └─ locales/ ├─ forum.pot ├─ site.pot ├─ de/ │ └─ LC_MESSAGES/ │ ├─ forum.mo │ ├─ forum.po │ ├─ site.mo │ └─ site.po ├─ es_ES/ │ └─ LC_MESSAGES/ │ └─ ... ├─ fr/ │ └─ ... ├─ pt_BR/ │ └─ ... └─ pt_PT/ └─ ...

观察上面示例:forum和site是两个域,因此de/LC_MESSAGES/下各有一对 PO/MO;每个 locale(de、es_ES、fr、pt_BR、pt_PT)都拥有自己独立的目录树。这正是前文「每语言一对 PO/MO、每域一个 POT」规则的落地形态。

复数规则(Plural Forms):让每种语言各得其所

正如引言所述,不同语言的复数规则千差万别。Gettext 又一次帮我们解决了这个麻烦:在创建新的.po文件时,你需要为该语言声明复数规则;对复数敏感的翻译条目,会为每条规则提供一个不同的翻译形式。代码中调用 Gettext 时只需给出句子相关的数字,Gettext 会自动计算应使用哪个形式——必要时还会做字符串替换。

复数规则由两部分组成:可用的复数形式数量(nplurals)和一个以n为变量的布尔测试(plural),该测试决定给定的数字落入哪条规则(计数从 0 开始)。原文给出的三个例子:

  • 日语:nplurals=1; plural=0—— 只有一种规则(日语不区分单复数)
  • 英语:nplurals=2; plural=(n != 1);—— 两种规则:n为 1 时用第一条,否则用第二条
  • 巴西葡萄牙语:nplurals=2; plural=(n > 1);—— 两种规则:n大于 1 时用第二条,否则用第一条

在实际项目中,你可以从公开的复数规则清单中复制所需语言的规则,而不必手动编写。当代码中调用 Gettext 处理带计数器的句子时,你必须同时传入相关数字;Gettext 会判断当前应生效的规则,并选用正确的本地化版本。相应地,.po文件中必须为每条已定义的复数规则各准备一个句子。

PO 文件实战示例:从表头到复数翻译

下面是一段.po文件节选——先不必纠结其格式细节,而是关注整体内容(后面会介绍如何轻松编辑它):

msgid "" msgstr "" "Language: pt_BR\n" "Content-Type: text/plain; charset=UTF-8\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" msgid "We are now translating some strings" msgstr "Nós estamos traduzindo algumas strings agora" msgid "Hello %1$s! Your last visit was on %2$s" msgstr "Olá %1$s! Sua última visita foi em %2$s" msgid "Only one unread message" msgid_plural "%d unread messages" msgstr[0] "Só uma mensagem não lida" msgstr[1] "%d mensagens não lidas"

逐段解读:

  1. 文件头(Header):msgid与msgstr均为空字符串,描述文件编码、复数规则等元信息。注意这里声明了Plural-Forms: nplurals=2; plural=(n > 1);,与上文的巴西葡萄牙语规则完全对应;
  2. 简单字符串翻译:将英文句子直接翻译成巴西葡萄牙语;
  3. 带替换符的翻译:借助sprintf的替换能力(%1$s、%2$s),译文可嵌入用户名与访问日期;
  4. 复数形式翻译:英文侧给出单数(msgid)与复数(msgid_plural)两个源串,葡萄牙语侧用msgstr[0]和msgstr[1]分别对应两条复数规则,并通过%d在句子中直接显示数字。

要点:复数形式始终有两个msgid(单数与复数),因此建议不要使用语法复杂的语言作为翻译的源语言(source of translation)。

翻译键(l10n keys)的两种流派之争

你可能已经注意到,本文示例直接把英文原句作为源 ID(msgid)。这个msgid在所有.po文件中保持一致——其他语言的文件格式相同、msgid字段相同,只是msgstr被翻译。

关于翻译键,业界主要有两种「学派」:

流派一:msgid就是真实句子

主要优势:

  • 如果软件在某些语言下存在未翻译的片段,屏幕上显示的键仍然保留含义——例如你从英语熟练翻译到西班牙语、但法语还需要帮助,可以先发布缺法语句子的新页面,网站未翻译部分会显示英文而不是乱码;
  • 翻译者更容易理解上下文,基于msgid做出准确翻译;
  • 免费获得「源语言」的本地化(源语言本身无需翻译);
  • 唯一劣势:如果后续需要修改实际文案,必须跨多个语言文件替换相同的msgid。

流派二:msgid是结构化唯一键

用结构化方式描述句子在应用中的角色,通常包含其所在的模板或部件位置(如top_menu.welcome),而非句子内容:

  • 优点:代码组织更清晰,文本内容与模板逻辑解耦;
  • 缺点:翻译者可能因缺少上下文而出错,需要一份源语言文件(如en.po)作为其他语言翻译的基础;
  • 缺失翻译时会显示无意义的键(例如在未翻译的法语页面上显示top_menu.welcome而非「Hello there, User!」)。这能倒逼翻译在发布前完整——但坏处是翻译问题会在界面上暴露得非常扎眼。部分库提供了「fallback(回退)」语言选项,行为与流派一类似。

权威倾向:Gettext 官方手册总体偏好第一种流派——它通常对翻译者和用户在出问题时更友好,本文也采用该方式;而 Symfony 文档则偏好基于关键字的翻译,以便所有翻译可以独立变更而不影响模板。

日常使用:一个完整的五步实战闭环

把以上理论串联起来,原文给出了从模板到上线的完整流程。核心思想是:在页面中书写静态文本时调用 Gettext 函数 → 这些句子进入.po文件 → 翻译后编译为.mo→ Gettext 在渲染界面时按当前 locale 取用。

第 1 步:在模板中调用 Gettext 函数

<?php include 'i18n_setup.php' ?> <div id="header"> <h1><?=sprintf(gettext('Welcome, %s!'), $name)?></h1> <!-- code indented this way only for legibility --> <?php if ($unread): ?> <h2><?=sprintf( ngettext('Only one unread message', '%d unread messages', $unread), $unread)?> </h2> <?php endif ?> </div> <h1><?=gettext('Introduction')?></h1> <p><?=gettext('We\'re now translating some strings')?></p>

这里涉及的核心函数族:

  • gettext():将msgid翻译为给定语言下的msgstr;别名_()作用完全相同;
  • ngettext():同样执行翻译,但按复数规则选择形式(第一个参数为单数形式,第二个为复数形式,第三个是用于判断的数字);
  • dgettext()/dngettext():允许在单次调用中覆盖域(domain)——域配置见下一步。

第 2 步:编写i18n_setup.php设置文件

这个文件负责选定正确的 locale 并完成 Gettext 的全部配置,是整套方案的「发动机」:

<?php /** * Verifies if the given $locale is supported in the project * @param string $locale * @return bool */ function valid($locale) { return in_array($locale, ['en_US', 'en', 'pt_BR', 'pt', 'es_ES', 'es']); } //setting the source/default locale, for informational purposes $lang = 'en_US'; if (isset($_GET['lang']) && valid($_GET['lang'])) { // the locale can be changed through the query-string $lang = $_GET['lang']; //you should sanitize this! setcookie('lang', $lang); //it's stored in a cookie so it can be reused } elseif (isset($_COOKIE['lang']) && valid($_COOKIE['lang'])) { // if the cookie is present instead, let's just keep it $lang = $_COOKIE['lang']; //you should sanitize this! } elseif (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { // default: look for the languages the browser says the user accepts $langs = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']); array_walk($langs, function (&$lang) { $lang = strtr(strtok($lang, ';'), ['-' => '_']); }); foreach ($langs as $browser_lang) { if (valid($browser_lang)) { $lang = $browser_lang; break; } } } // here we define the global system locale given the found language putenv("LANG=$lang"); // this might be useful for date functions (LC_TIME) or money formatting (LC_MONETARY), for instance setlocale(LC_ALL, $lang); // this will make Gettext look for ../locales/<lang>/LC_MESSAGES/main.mo bindtextdomain('main', '../locales'); // indicates in what encoding the file should be read bind_textdomain_codeset('main', 'UTF-8'); // if your application has additional domains, as cited before, you should bind them here as well bindtextdomain('forum', '../locales'); bind_textdomain_codeset('forum', 'UTF-8'); // here we indicate the default domain the gettext() calls will respond to textdomain('main'); // this would look for the string in forum.mo instead of main.mo // echo dgettext('forum', 'Welcome back!'); ?>

逐行拆解这套配置的职责:

代码作用
valid($locale)白名单校验,仅接受项目已支持的 locale(注意这不是输入净化的替代品——原文代码注释反复强调you should sanitize this!,这与仓库 数据过滤章节 中「永远不要信任外部输入」的原则一致)
$_GET['lang']→ cookie允许通过查询字符串切换语言,并用 cookie 记忆用户选择
HTTP_ACCEPT_LANGUAGE解析兜底策略:按浏览器声明可接受的语言列表逐项匹配白名单(将-规范化为_,如en-US→en_US),首个命中者即为当前语言
putenv("LANG=$lang")设置全局系统 locale,供底层库感知
setlocale(LC_ALL, $lang)影响日期函数(LC_TIME)、货币格式化(LC_MONETARY)等类别——这与仓库 Date and Time 章节 讨论的时间处理互为表里
bindtextdomain('main', '../locales')绑定域main到翻译目录,使 Gettext 查找../locales/<lang>/LC_MESSAGES/main.mo
bind_textdomain_codeset('main', 'UTF-8')声明文件读取编码——UTF-8 至关重要,与仓库 UTF-8 章节 关于编码一致性(mb_*函数、utf8mb4、mb_internal_encoding等)的建议一脉相承
textdomain('main')设置默认域,使普通gettext()调用响应main域
dgettext('forum', ...)(注释)演示单次调用切换到forum域的场景

第 3 步:用 Poedit 完成首次翻译准备

Gettext 相对于框架内置 i18n 包的一大优势,就是其强大而规范的文件格式。也许你会想「这格式太难手工编辑了,一个数组不是更容易吗?」——但像 Poedit 这样的应用就是来帮你解决这个问题的:它免费、全平台可用、上手容易又足够强大,能利用 Gettext 的全部特性。本指南基于 Poedit 1.8 编写。

首次运行流程如下:

  1. 菜单选择File > New...,立即会被问到目标语言:可选择/筛选要翻译的语言,也可以直接用en_US或pt_BR这类格式;
  2. 按前文约定的目录结构保存文件;
  3. 点击Extract from sources(从源码提取),配置提取与翻译任务的各项设置——这些设置之后随时可以在Catalog > Properties中找回:
    • Source paths(源路径):必须包含项目中所有调用gettext()(及同族函数)的目录,通常是 templates/views 目录。这是唯一必填项;
    • Translation properties(翻译属性):项目名与版本、团队及团队邮箱(写入.po文件头);**Plural forms(复数规则)**填入上文所述的规则(有示例链接,大多数情况可保持默认——Poedit 内置了多语言的复数规则数据库);**Charsets(字符集)**建议 UTF-8;**Source code charset(源码字符集)**设为代码库所用的字符集,通常也是 UTF-8;
    • Source keywords(源关键字):底层软件认识多种编程语言中gettext()及类似函数调用的样子,但你也可以注册自己的翻译函数——这正是第 4 步的扩展点。
  4. 配置完成后,Poedit 会扫描源码找出所有本地化调用,并显示发现/移除的摘要;新条目以空值进入翻译表,你在其中输入各字符串的本地化版本;
  5. 保存后,.mo文件会在同一目录被(重新)编译——项目即完成国际化。

第 4 步:翻译字符串与持续维护

正如前文所见,本地化字符串主要有两类:

  • 简单字符串:只有「源字符串」和「本地化字符串」两个框。源字符串不可在 Poedit 中修改——Gettext/Poedit 没有能力改动你的源文件,要改文案应改源码后重新扫描。提示:右键点击某条翻译行,Poedit 会提示该字符串在哪些源文件的哪些行被使用;
  • 复数形式字符串:显示两个源字符串框(单数+复数),并以标签页形式让你配置不同复数规则下的最终形式。

每当源码变更需要更新翻译时,点击Refresh,Poedit 会重新扫描代码:移除已不存在的条目、合并变更的条目、新增条目。它还可能根据已有翻译猜测部分新翻译;这些猜测以及变更过的条目会被打上"Fuzzy"(模糊)标记(列表中以金色显示),表示需要人工复查。这一机制对翻译团队协作尤其有用:拿不准的翻译就标记 Fuzzy,留给他人复核。

最后建议始终勾选View > Untranslated entries first(先看未翻译条目),这能极大帮助你避免遗漏任何条目;同一菜单下还可以打开界面部件,按需为翻译者留下上下文信息。

第 5 步:常见坑点与进阶技巧(Tips & Tricks)

可能的缓存问题

如果你在 Apache 上以模块方式运行 PHP(mod_php),可能会遇到.mo文件被缓存的问题:首次读取后缓存生效,后续要更新翻译可能需要重启服务器。在 Nginx + PHP5 下通常只需刷新几次页面即可更新翻译缓存,PHP7 下则很少需要。

自定义辅助函数与提取器配置

很多人偏爱用_()代替gettext()(框架的自定义 i18n 库也常提供类似t()的短函数,让代码更简洁)。但注意:_()是唯一有官方别名的函数。你可以在项目中补充自己的辅助函数,例如:

  • __():普通翻译;
  • _n():对应ngettext()的复数翻译;
  • _r():把gettext()与sprintf()串起来的「带替换的翻译」。

一旦引入这些新函数,就需要教给 Gettext 提取器如何从中抽取字符串。方法很简单:只需在.po文件的一个字段中(Poedit 里是Catalog > Properties > Source keywords)按特定格式声明:

  • 若创建的是像t()这样「唯一参数即待翻译字符串」的函数,直接写t即可——Gettext 会知道唯一的函数参数就是要翻译的字符串;
  • 若函数有多个参数,则需指明第一个字符串在哪个参数位,必要时还要指明复数形式的位置。例如调用__('one user', '%d users', $number),声明为__:1,2表示第一个形式是第 1 个参数、第二个形式是第 2 个参数;若数字放在第一个参数位(如__($number, 'one user', '%d users')),声明则应写__:2,3。

声明完毕后重新扫描,新函数中的字符串就会像之前一样被自动提取进来。

将 i18n/l10n 放回 PHP: The Right Way 的完整上下文

本文所依据的章节是 PHP: The Right Way 仓库中「Coding Practices(编码实践)」部分的子章节。该仓库是一个 Jekyll 项目(见 README.md 与 _config.yml),每个章节对应_posts/下的一个 Markdown 文件,子章节通过 front matter 中的isChild: true标识,导航与页面结构自动生成(见 index.html 与 _layouts/default.html)。

将 i18n/l10n 与仓库其他章节联动,才能构成一套完整的多语言应用实践:

  • 编码一致性:PHP and UTF-8 章节 强调 PHP 底层不原生支持 Unicode,必须全链路使用mb_*函数、显式声明编码(mb_internal_encoding、mb_http_output)、数据库采用utf8mb4——这与bind_textdomain_codeset('main', 'UTF-8')、PO 文件表头的charset=UTF-8是同一套「处处 UTF-8」纪律的不同侧面;
  • 输入安全:i18n 设置文件中的$_GET['lang']、$_COOKIE['lang']都是典型的外部输入,Data Filtering 章节 的原则(filter_var()/filter_input()校验、HTML 输出转义)同样适用于 locale 参数——这也是原文档代码中反复注释you should sanitize this!的原因;
  • 时间与货币本地化:setlocale(LC_ALL, $lang)会影响日期与货币格式化,与 Date and Time 章节 中DateTime、DateInterval的使用配合,可实现真正的「区域感知」界面。

结语

从概念上区分 i18n/l10n/复数规则,到工具选型时认清数组方案的边界,再到 Gettext 的 POT/PO/MO 文件体系、域与 locale 规范、目录结构、复数规则、PO 文件写法、翻译键流派之争,最后落到gettext()/ngettext()/dgettext()调用、i18n_setup.php完整配置与 Poedit 的扫描—翻译—编译—刷新闭环——这套方法论足以支撑一个 PHP 项目从单语言走向多语言、多区域。实践中的两个核心纪律值得反复强调:locale 输入同样需要净化(衔接仓库的数据过滤章节),编码全程保持 UTF-8(衔接仓库的 UTF-8 章节)。基于此,再配合你选定的 i18n 库或 Gettext 工具链,即可稳健地管理日益增长的翻译资产。

如需继续深入,可在本仓库中对照阅读:Internationalization and Localization 原文、PHP and UTF-8、Date and Time、Data Filtering,以及 Components 章节 中列举的社区 i18n 组件。

  • 文档
  • 教程

【免费下载链接】php-the-right-way

An easy-to-read, quick reference for PHP best practices, accepted coding standards, and links to authoritative tutorials around the Web

项目地址:https://gitcode.com/gh_mirrors/ph/php-the-right-way
点击查看免费下载

相关推荐

上一篇:终极指南:如何将GLM-4模型从PyTorch无缝迁移到TensorFlow
下一篇:三分钟搞定Java多版本冲突:jenv环境管理实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询