如何构建多语言应用程序:使用 PHP 和 Gettext 的演示

已发表: 2022-03-11

无论您是构建网站还是成熟的 Web 应用程序,要让更广泛的受众可以访问它,通常需要它以不同的语言和区域设置可用。

大多数人类语言之间的根本差异使这绝非易事。 语法规则、语言细微差别、日期格式等方面的差异使本地化成为一项独特而艰巨的挑战。

考虑这个简单的例子。

英语中的复数规则非常简单:你可以有一个单词的单数形式或一个单词的复数形式。

然而,在其他语言中——例如斯拉夫语言——除了单数形式外,还有两种复数形式。 您甚至可以找到总共有四种、五种或六种复数形式的语言,例如斯洛文尼亚语、爱尔兰语或阿拉伯语。

代码的组织方式以及组件和界面的设计方式对于确定应用程序本地化的难易程度起着重要作用。

代码库的国际化 (i18n) 有助于确保它可以相对轻松地适应不同的语言或地区。 国际化通常只进行一次,最好是在项目开始时进行,以避免以后需要对源代码进行巨大更改。

如何构建多语言应用程序:使用 PHP 和 Gettext 的演示

一旦您的代码库国际化,本地化(l10n) 就变成了将应用程序的内容翻译成特定语言/区域设置的问题。

每次需要支持新的语言或地区时,都需要执行本地化。 此外,每当界面的一部分(包含文本)被更新时,新的内容就会变得可用——然后需要将其本地化(即翻译)到所有支持的语言环境。

在本文中,我们将学习如何对用 PHP 编写的软件进行国际化和本地化。 我们将通过各种实施选项和我们可以使用的不同工具来简化流程。

国际化工具

使 PHP 软件国际化的最简单方法是使用数组文件。 数组将填充翻译后的字符串,然后可以从模板中查找:

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

然而,对于严肃的项目来说,这几乎不是推荐的方式,因为它肯定会在未来造成维护问题。 有些问题甚至可能在一开始就出现,例如缺乏对变量插值或名词复数的支持等。

最经典的工具之一(通常作为 i18n 和 l10n 的参考)是一个名为 Gettext 的 Unix 工具。

虽然可以追溯到 1995 年,但它仍然是一款易于使用的翻译软件的综合工具。 虽然它很容易上手,但它仍然具有强大的支持工具。

Gettext 是我们将在这篇文章中使用的。 我们将展示一个出色的 GUI 应用程序,可用于轻松更新您的 l10n 源文件,从而避免处理命令行的需要。

图书馆让事情变得简单

支持 Gettext 的主要 PHP Web 框架和库

有主要的 PHP Web 框架和库支持 Gettext 和 i18n 的其他实现。 有些比其他更容易安装,或者具有附加功能或支持不同的 i18n 文件格式。 尽管在本文档中,我们关注的是 PHP 核心提供的工具,但这里列出了一些其他值得一提的工具:

  • oscarotero/Gettext:具有面向对象接口的 Gettext 支持; 包括改进的辅助函数、多种文件格式的强大提取器(其中一些不被gettext命令本机支持)。 还可以导出为 .mo/.po 文件以外的格式,如果您需要将翻译文件集成到系统的其他部分(如 JavaScript 界面)中,这将非常有用。

  • symfony/translation:支持很多不同的格式,但建议使用详细的 XLIFF。 不包括辅助函数或内置提取器,但支持在内部使用strtr()占位符。

  • zend/i18n:支持数组和 INI 文件,或 Gettext 格式。 实现缓存层以避免每次都需要读取文件系统。 还包括视图助手、区域感知输入过滤器和验证器。 但是,它没有消息提取器。

其他框架也包括 i18n 模块,但这些模块在其代码库之外不可用:

  • Laravel:支持基本数组文件; 没有自动提取器,但包含用于模板文件的@lang帮助器。

  • Yii:支持数组、Gettext 和基于数据库的翻译,并包含消息提取器。 由Intl扩展支持,自 PHP 5.3 起可用,并基于 ICU 项目。 这使 Yii 能够运行强大的替换功能,例如拼写数字、格式化日期、时间、间隔、货币和序数。

如果您决定选择不提供提取器的库之一,您可能希望使用 Gettext 格式,因此您可以使用本章其余部分所述的原始 Gettext 工具链(包括 Poedit)。

安装 Gettext

您可能需要使用包管理器(如 apt-get 或 yum)安装 Gettext 和相关的 PHP 库。 安装后,通过将extension=gettext.so (Linux/Unix) 或extension=php_gettext.dll (Windows) 添加到您的php.ini文件来启用它。

在这里,我们还将使用 Poedit 创建翻译文件。 您可能会在系统的包管理器中找到它; 它适用于 Unix、Mac 和 Windows,也可以在其网站上免费下载。

Gettext 文件的类型

在使用 Gettext 时,您通常会处理三种文件类型。

主要是 PO(便携式对象)和 MO(机器对象)文件,第一个是可读的“翻译对象”列表,第二个是相应的二进制文件(在进行本地化时由 Gettext 解释)。 还有一个 POT(PO 模板)文件,它只包含源文件中的所有现有密钥,可用作生成和更新所有 PO 文件的指南。

模板文件不是强制性的; 根据您用于执行 l10n 的工具,您只需使用 PO/MO 文件就可以了。 对于每种语言和地区,您将拥有一对 PO/MO 文件,但每个域只有一个 POT。

分离域

在某些情况下,在大型项目中,当相同的词在不同的上下文中传达不同的含义时,您可能需要分开翻译。

在这些情况下,您需要将它们拆分为不同的“域”,这些“域”基本上是命名为 POT/PO/MO 文件的组,其中文件名是所述翻译域

为简单起见,中小型项目通常只使用一个域; 它的名称是任意的,但我们将在代码示例中使用“main”。

例如,在 Symfony 项目中,域用于分隔验证消息的翻译。

语言环境代码

语言环境只是标识一种语言版本的代码。 它是根据 ISO 639-1 和 ISO 3166-1 alpha-2 规范定义的:语言的两个小写字母,可选地后跟一个下划线和两个大写字母标识国家或地区代码。

对于稀有语言,使用三个字母。

对于一些发言者来说,国家部分似乎是多余的。 事实上,有些语言在不同国家有方言,例如奥地利德语(de_AT)或巴西葡萄牙语(pt_BR)。 第二部分用于区分这些方言——当它不存在时,它被视为语言的“通用”或“混合”版本。

目录结构

要使用 Gettext,我们需要遵守特定的文件夹结构。

首先,您需要为源存储库中的 l10n 文件选择任意根。 在其中,您将拥有每个所需语言环境的文件夹,以及一个固定的“LC_MESSAGES”文件夹,其中将包含您的所有 PO/MO 对。

LC_MESSAGES 文件夹

复数形式

正如我们在介绍中所说,不同的语言可能具有不同的复数规则。 但是,Gettext 为我们省去了这个麻烦。

在创建新的 .po 文件时,您必须声明该语言的复数规则,并且对复数敏感的翻译片段将针对每个规则具有不同的形式。

在代码中调用 Gettext 时,您必须指定与句子相关的数字(例如,对于短语“您有 n 条消息。”,您需要指定 n 的值),它会计算出正确的形式使用 - 如果需要,甚至使用字符串替换。

复数规则由必要的规则数量组成,每个规则都有一个布尔测试(最多可以省略一个规则的测试)。 例如:

  • 日语: nplurals=1; plural=0; nplurals=1; plural=0; - 一条规则:没有复数形式

  • 英语: nplurals=2; plural=(n != 1); nplurals=2; plural=(n != 1); - 两条规则:仅当 n 不为 1 时使用复数形式,否则使用单数形式。

  • 巴西葡萄牙语: nplurals=2; plural=(n > 1); nplurals=2; plural=(n > 1); - 两条规则,仅当 n 大于 1 时使用复数形式,否则使用单数形式。

如需更深入的解释,请在线获取内容丰富的 LingoHub 教程。

Gettext 将根据提供的数字确定要使用的规则,并将使用正确的本地化版本的字符串。 对于需要处理复数形式的字符串,您需要在 .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're now translating some strings" msgstr "Nos estamos traduzindo algumas strings agora" msgid "Hello %1$s! Your last visit was on %2$s" msgstr "Ola %1$s! Sua ultima visita foi em %2$s" msgid "Only one unread message" msgid_plural "%d unread messages" msgstr[0] "So uma mensagem nao lida" msgstr[1] "%d mensagens nao lidas"

第一部分像标题一样工作, msgidmsgstr为空。

它描述了文件编码、复数形式和其他一些内容。 第二部分将一个简单的字符串从英语翻译成巴西葡萄牙语,第三部分也是如此,但利用sprintf的字符串替换,使翻译能够包含用户名和访问日期。

最后一节是复数形式的示例,在英语中显示单数和复数版本为msgid ,其对应的翻译为msgstr 0 和 1(遵循复数规则给出的数字)。

在那里,也使用了字符串替换,因此可以使用%d直接在句子中看到数字。 复数形式总是有两个msgid (单数和复数),因此建议不要使用复杂的语言作为翻译源。

本地化键

您可能已经注意到,我们使用实际的英文句子作为源 ID。 该msgid在您的所有 .po 文件中使用相同,这意味着其他语言将具有相同的格式和相同的msgid字段,但翻译了msgstr行。

说到翻译键,这里有两种标准的“哲学”方法:

1.msgid作为真句

这种方法的主要优点是:

  • 如果软件的某些部分未翻译成任何给定语言,则显示的键仍将保持某些含义。 例如,如果您知道如何将英语翻译成西班牙语,但在翻译成法语时需要帮助,您可能会发布缺少法语句子的新页面,而网站的部分内容将改为以英语显示。

  • 翻译者更容易理解正在发生的事情并根据msgid进行适当的翻译。

  • 它为您提供一种语言的“免费”l10n - 源语言。

另一方面,主要缺点是,如果您需要更改实际文本,则需要跨多个语言文件替换相同的msgid

2. msgid 作为唯一的结构化键

这将以结构化的方式描述应用程序中的句子角色,包括字符串所在的模板或部分,而不是其内容。

这是组织代码的好方法,将文本内容与模板逻辑分开。 但是,这可能会给错过上下文的翻译人员带来问题。

需要一个源语言文件作为其他翻译的基础。 例如,理想情况下,开发人员应该有一个“en.po”文件,翻译人员会阅读该文件以了解在“fr.po”中要写什么。

缺少翻译会在屏幕上显示无意义的键(“top_menu.welcome”而不是“Hello there,User!”在所述未翻译的法语页面上)。

这很好,因为它会强制翻译在发布之前完成 - 但不好的是翻译问题在界面中真的很糟糕。 但是,一些库包含一个选项,可以将给定语言指定为“后备”,具有与其他方法类似的行为。

Gettext 手册偏爱第一种方法,因为通常情况下,翻译人员和用户更容易遇到问题。 这也是我们将在这里使用的方法。

不过应该注意的是,Symfony 文档支持基于关键字的翻译,以允许独立更改所有翻译而不影响模板。

日常使用

在一个常见的应用程序中,您将在页面中编写静态文本时使用一些 Gettext 函数。

然后这些句子将出现在 .po 文件中,被翻译,编译成 .mo 文件,然后在渲染实际界面时由 Gettext 使用。 鉴于此,让我们将到目前为止所讨论的内容结合在一起,以一步一步的示例为例:

1. 一个示例模板文件,包括一些不同的 gettext 调用

<?php include 'i18n_setup.php' ?> <div> <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() ,允许您为单个调用覆盖域(下一个示例中有关域配置的更多信息)

2. 一个示例设置文件(如上所用的 i18n_setup.php),选择正确的语言环境并配置 Gettext

使用 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!'); ?>

3. 准备第一次运行的翻译

Gettext 相对于自定义框架 i18n 包的一大优势是其广泛而强大的文件格式。

也许你在想“哦,天哪,这很难理解和手动编辑,一个简单的数组会更容易!” 毫无疑问,像 Poedit 这样的应用程序可以提供帮助 - 很多。 您可以从他们的网站获取该程序,它是免费的,适用于所有平台。 这是一个非常容易习惯的工具,同时也是一个非常强大的工具 - 使用 Gettext 提供的所有功能。 我们将在这里使用最新版本 Poedit 1.8。

查看 Poedit 内部。

在第一次运行时,您应该从菜单中选择“文件 > 新建...”。 您将被要求提供语言; 选择/过滤您要翻译的语言,或使用我们之前提到的格式,例如en_USpt_BR

选择语言。

现在,保存文件 - 使用我们提到的目录结构。 然后您应该单击“从源中提取”,在这里您将配置提取和翻译任务的各种设置。 您稍后可以通过“目录 > 属性”找到所有这些:

  • 源路径:包括项目中调用gettext() (和兄弟姐妹)的所有文件夹 - 这通常是您的模板/视图文件夹。 这是唯一的强制设置。

  • 翻译属性:

    • 项目名称和版本、团队和团队的电子邮件地址: .po 文件标题中的有用信息。
    • 复数形式:这些是我们之前提到的规则。 大多数情况下,您可以将其保留为默认选项,因为 Poedit 已经包含一个方便的数据库,其中包含许多语言的复数规则。
    • 字符集:最好是 UTF-8。
    • 源代码字符集:您的代码库使用的字符集 - 也可能是 UTF-8,对吧?
  • 源关键字:底层软件知道gettext()和类似函数调用在几种编程语言中的外观,但您不妨创建自己的翻译函数。 您将在这里添加其他方法。 这将在后面的“提示”部分中讨论。

设置这些属性后,Poedit 将扫描您的源文件以查找所有本地化调用。 每次扫描后,Poedit 将显示从源文件中找到的内容和删除的内容的摘要。 转换表中的新条目将是空的,允许您输入这些字符串的本地化版本。 保存它,一个 .mo 文件将被(重新)编译到同一个文件夹中,并且,presto!,您的项目已国际化!

项目国际化。

Poedit 还可以建议来自网络和以前文件的常见翻译。 它很方便,因此您只需检查它们是否有意义并接受它们。 如果您不确定翻译,可以将其标记为模糊,它将显示为黄色。 蓝色条目是那些没有翻译的条目。

4. 翻译字符串

您可能已经注意到,本地化字符串有两种主要类型:简单字符串和复数形式的字符串。

简单的只有两个框:源和本地化字符串。 无法修改源字符串,因为 Gettext/Poedit 不包括更改源文件的功能; 相反,您需要更改源本身并重新扫描文件。 (提示:如果您右键单击翻译行,它将显示源文件和正在使用该字符串的行的提示。)

复数形式的字符串包括两个显示两个源字符串的框和选项卡,因此您可以配置不同的最终形式。

配置最终形式。

Poedit 上具有复数形式的字符串示例,显示每个字符串的翻译选项卡。

每当您更改源代码文件并需要更新翻译时,只需点击刷新,Poedit 就会重新扫描代码,删除不存在的条目,合并已更改的条目并添加新条目。

Poedit 也可能会尝试根据您所做的其他翻译来猜测一些翻译。 这些猜测和更改的条目将收到一个“模糊”标记,表明它们需要审查,在列表中以黄色显示。

如果您有一个翻译团队并且有人试图写一些他们不确定的东西,这也很有用:只需将其标记为模糊,其他人稍后会对其进行审核。

最后,建议保留“查看 > 未翻译的条目优先”标记,因为它可以帮助您避免忘记任何条目。 从该菜单中,您还可以打开 UI 的某些部分,以便您在需要时为翻译人员留下上下文信息。

提示与技巧

Web 服务器最终可能会缓存您的 .mo 文件。

如果您在 Apache (mod_php) 上将 PHP 作为模块运行,您可能会遇到 .mo 文件被缓存的问题。 它在第一次读取时发生,然后,要更新它,您可能需要重新启动服务器。

在 Nginx 和 PHP5 上,刷新翻译缓存通常只需要几次页面刷新,而在 PHP7 上很少需要。

库提供帮助函数来保持本地化代码的简短。

许多人喜欢使用_()而不是gettext()更容易。 许多来自框架的自定义 i18n 库也使用类似于t()的东西,以使翻译后的代码更短。 但是,这是唯一具有快捷方式的功能。

你可能想在你的项目中添加一些其他的东西,比如__()_n()用于ngettext() ,或者可能是一个花哨的_r()来加入gettext()sprintf()调用。 其他库,例如 oscarotero 的 Gettext 也提供了类似的辅助函数。

在这些情况下,您需要指导 Gettext 实用程序如何从这些新函数中提取字符串。 不要害怕,这很容易。 它只是 .po 文件中的一个字段或 Poedit 中的设置屏幕(在编辑器中,该选项位于“目录 > 属性 > 源关键字”中)。

请记住:Gettext 已经知道许多语言的默认函数,因此如果该列表看起来是空的,请不要担心。 您需要按照以下特定格式在该列表中包含新功能的规范:

  • 如果你创建类似t()的东西,它只是返回一个字符串的翻译,你可以将它指定为t 。 Gettext 将知道唯一的函数参数是要翻译的字符串;

  • 如果函数有多个参数,您可以指定第一个字符串在哪个参数中,如果需要,还可以指定复数形式。 例如,如果我们的函数签名是__('one user', '%d users', $number) ,规范将是__:1,2 ,这意味着第一个形式是第一个参数,第二个形式是第二个论点。 如果您的数字作为第一个参数出现,则规范将为__:2,3 ,表示第一种形式是第二个参数,依此类推。

在 .po 文件中包含这些新规则后,新的扫描将像以前一样轻松地引入您的新字符串。

使用 Gettext 使您的 PHP 应用程序多语言

Gettext 是一个非常强大的工具,用于将您的 PHP 项目国际化。 除了支持大量人类语言的灵活性之外,它还支持 20 多种编程语言,让您可以轻松地将使用 PHP 的知识转移到 Python、Java 或 C# 等其他语言。

此外,Poedit 可以帮助平滑代码和翻译字符串之间的路径,使过程更加直接和易于遵循。 它还可以通过其 Crowdin 集成来简化共享翻译工作。

只要有可能,请考虑您的用户可能会说的其他语言。 这对于非英语项目最为重要:如果您以英语和您的母语发布它,您可以提高用户访问权限。

当然,并非所有项目都需要国际化,但在项目初期启动 i18n(即使最初不需要)要比在以后成为要求时再做要容易得多。 而且,使用 Gettext 和 Poedit 等工具,它比以往任何时候都容易。

相关: PHP 7 简介:新功能和已消失的功能