PHP 之道

PHP 开发实践

PHP 是一门广博的语言,它让各个水平的开发者都能快速、高效地产出代码。然而,在学习这门语言的过程中,为了图省事或因为养成了坏习惯,我们常常会遗忘(或忽视)最初学到的一些基础知识。

为了解决这个普遍存在的问题,本章旨在提醒开发者们牢记 PHP 中的基本编码实践。

1. 基础回顾

延伸阅读:

继续阅读 PHP 基础知识 (The Basics)

2. 日期和时间 (Date and Time)

PHP 提供了一个强大的 DateTime 类,专门用于读取、写入、比较或计算日期和时间。虽然 PHP 中除了 DateTime 之外还有许多其他日期相关的函数,但它为绝大多数常见场景提供了一个优雅的面向对象接口DateTime 还可以完美处理时区问题(尽管这超出了本简短介绍的范围)。

要开始使用 DateTime,你可以通过工厂方法 createFromFormat() 将原始的日期/时间字符串转换为对象,或者直接 new DateTime 来获取当前时间。要将 DateTime 对象转换回字符串以便输出,请使用 format() 方法。

<?php
$raw = '22. 11. 1968';
// 根据指定格式解析字符串
$start = DateTime::createFromFormat('d. m. Y', $raw);
echo '开始日期: ' . $start->format('Y-m-d') . PHP_EOL;

2.1 日期计算与比较

使用 DateInterval 类可以进行日期计算。DateTime 拥有 add()(加)和 sub()(减)等方法,这些方法接收一个 DateInterval 对象作为参数。

强烈建议:不要编写“假设每天都有固定秒数”的代码(比如用 $timestamp + 86400 算明天)。夏令时和时区的改变会彻底打破这种假设。 请务必使用日期时间间隔 (DateInterval)。要计算两个日期的差值,请使用 diff() 方法。它会返回一个新的 DateInterval 对象,非常容易格式化输出。

<?php
// 创建 $start 的副本,并增加 1 个月零 6 天
$end = clone $start;
// P1M6D: P=Period, 1M=1 Month, 6D=6 Days
$end->add(new DateInterval('P1M6D'));

$diff = $end->diff($start);
echo '差值: ' . $diff->format('%m 个月, %d 天 (总计: %a 天)') . PHP_EOL;
// 输出: 差值: 1 个月, 6 天 (总计: 37 天)

你可以直接对 DateTime 对象使用标准的比较运算符:

<?php
if ($start < $end) {
    echo "开始时间早于结束时间!" . PHP_EOL;
}

2.2 日期周期迭代

最后一个例子展示了 DatePeriod 类的用法。它用于遍历重复发生的事件。它接收三个参数:开始时间对象、时间间隔和结束时间对象,然后它会返回这段时间内的所有事件节点。

<?php
// 输出 $start 和 $end 之间所有的“星期四”
$periodInterval = DateInterval::createFromDateString('first thursday');
// EXCLUDE_START_DATE 表示结果中不包含起始日期本身
$periodIterator = new DatePeriod($start, $periodInterval, $end, DatePeriod::EXCLUDE_START_DATE);

foreach ($periodIterator as $date) {
    // 循环输出周期内的每个日期
    echo $date->format('Y-m-d') . ' ';
}

提示: PHP 社区非常流行一个名为 Carbon 的 API 扩展包。它继承了 DateTime 类的所有特性,因此几乎不需要修改现有代码,同时它还额外提供了本地化支持、更丰富的增减与格式化方法,以及用于测试的“时间模拟”功能。

延伸阅读:

3. 设计模式 (Design Patterns)

在构建应用程序时,在代码中运用常见的设计模式,并为项目的整体结构采用成熟的架构模式是非常有益的。使用常见模式能让代码管理变得更容易,也能让其他开发者迅速理解系统是如何协同工作的。

如果你使用了某个框架,那么大部分底层代码和项目结构都已经由框架决定了,很多关于模式的决策框架已经替你做好了。但是,在框架之上编写的业务代码中,选择遵循哪些最佳模式依然是你的责任

另一方面,如果你没有使用框架,而是从零开始构建应用,你就必须自己去寻找最适合当前项目类型和规模的模式。

延伸阅读:

4. 处理 UTF-8 编码

本小节最初由 Alex Cabal 在 PHP Best Practices 中撰写,并作为我们 UTF-8 建议的基础。

处理 UTF-8 没有什么“一键搞定”的魔法。你必须小心谨慎、关注细节并保持一致。

目前的 PHP 在底层并不原生支持 Unicode。虽然有方法可以确保 UTF-8 字符串被正确处理,但这并不容易,它要求你深入到 Web 应用的几乎所有层面——从 HTML 到 SQL 再到 PHP 代码。我们将尽量给出一个简明实用的总结。

4.1 PHP 层面的 UTF-8

一些基本的字符串操作(如连接两个字符串、赋值)不需要为 UTF-8 做特殊处理。然而,绝大多数核心字符串函数(如 strpos()strlen())却需要特别注意。

这些函数通常都有一个以 mb_ 开头的对应版本:例如 mb_strpos()mb_strlen()。这些 mb_* 函数由 Multibyte String Extension (多字节字符串扩展) 提供,专门设计用于操作 Unicode 字符串。

当操作 Unicode 字符串时,你必须使用 mb_* 函数。 例如,如果你对一个包含中文的 UTF-8 字符串使用 substr(),很有可能会截断半个字符,导致乱码。正确的做法是使用多字节对应的 mb_substr()

最困难的地方在于始终牢记使用 mb_* 函数。只要你忘记了一次,你的 Unicode 字符串在后续处理中就有被破坏的风险。
(注意:并非所有字符串函数都有 mb_* 对应版本。如果没有,你可能得自己想办法绕过。)

你应当在你编写的每个 PHP 脚本(或全局包含文件的顶部)使用 mb_internal_encoding() 函数,如果脚本要向浏览器输出内容,紧接着还要调用 mb_http_output()。在每个脚本中显式定义字符串编码,能在未来为你省去大量令人头疼的麻烦。

此外,许多操作字符串的 PHP 函数都有一个可选参数,允许你指定字符编码。只要有这个选项,你总是应该显式指定为 UTF-8。例如,处理包含 HTML 特殊字符的字符串时,总是应该为 htmlentities() 指定 UTF-8。(注:从 PHP 5.4.0 开始,UTF-8 已成为 htmlentities()htmlspecialchars() 的默认编码。)

最后,如果你正在构建一个分布式应用,且无法保证运行环境一定开启了 mbstring 扩展,可以考虑引入 Composer 包 symfony/polyfill-mbstring。它会在扩展存在时使用原生功能,不存在时回退到兼容模式,防止报错。

4.2 数据库层面的 UTF-8

如果你的 PHP 脚本访问 MySQL,即便你在 PHP 里做足了防护,字符串存入数据库时仍有可能不是 UTF-8。

为了确保字符串从 PHP 到 MySQL 都能保持 UTF-8,请确保你的数据库数据表的字符集及排序规则(Collation)都设置为 utf8mb4,并且在 PDO 连接字符串中也使用 utf8mb4 字符集。请参考下方的代码示例。这一点极其重要!

       注意: 为了获得完整的 UTF-8 支持(包含 Emoji 表情等),你必须使用 utf8mb4 字符集,而绝对不能使用 MySQL 旧的 utf8 字符集!详情请参阅文末的延伸阅读。

4.3 浏览器层面的 UTF-8

使用 mb_http_output() 函数确保你的 PHP 脚本向浏览器输出 UTF-8 字符串。

接着,需要通过 HTTP 响应头告诉浏览器“本页面应被解析为 UTF-8”。如今最标准的做法是在 PHP 中设置 HTTP 响应头:

<?php
header('Content-Type: text/html; charset=UTF-8');

过去的传统做法是在页面的 <head> 标签中包含 <meta charset="UTF-8">,现在两者结合使用最为稳妥。

4.4 完整的 UTF-8 综合示例

<?php
// 告诉 PHP 我们在脚本中统一使用 UTF-8 字符串
mb_internal_encoding('UTF-8');
$utf_set = ini_set('default_charset', 'utf-8');
if (!$utf_set) {
    throw new Exception('无法将 default_charset 设置为 utf-8,请检查系统配置!');
}
// 告诉 PHP 我们将向浏览器输出 UTF-8 内容
mb_http_output('UTF-8');

// 我们的 UTF-8 测试字符串 (包含特殊字符)
$string = 'Êl síla erin lû e-govaned vîn.';

// 使用多字节函数转换字符串
// 注意:为了演示,我们故意在非 ASCII 字符处截断字符串
$string = mb_substr($string, 0, 15);

// 连接数据库存储转换后的字符串
// 注意数据源名称 (DSN) 中的 `charset=utf8mb4`
$link = new PDO(
    'mysql:host=localhost;dbname=your-db;charset=utf8mb4',
    'your-username',
    'your-password',
    array(
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_PERSISTENT => false
    )
);

// 将字符串以 UTF-8 存入数据库
// 前提:你的数据库和表都已经使用了 utf8mb4 字符集
$handle = $link->prepare('insert into ElvishSentences (Id, Body, Priority) values (default, :body, :priority)');
$handle->bindParam(':body', $string, PDO::PARAM_STR);
$priority = 45;
$handle->bindParam(':priority', $priority, PDO::PARAM_INT); // 明确告知 PDO 这是一个整数
$handle->execute();

// 读取刚刚存储的字符串,证明它被正确保存了
$handle = $link->prepare('select * from ElvishSentences where Id = :id');
$id = 7;
$handle->bindParam(':id', $id, PDO::PARAM_INT);
$handle->execute();

// 将结果抓取到一个对象中,稍后在 HTML 中输出
$result = $handle->fetchAll(\PDO::FETCH_OBJ);

// 一个用于将数据安全输出到 HTML 的辅助函数
function escape_to_html($dirty){
    echo htmlspecialchars($dirty, ENT_QUOTES, 'UTF-8');
}

// 设置 HTTP 头信息 (如果 default_charset 已设置,这行其实可以省略)
header('Content-Type: text/html; charset=UTF-8'); 
?>
<!doctype html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>UTF-8 测试页</title>
    </head>
    <body>
        <?php
        foreach($result as $row){
            // 这应该能向浏览器正确输出我们转换过的 UTF-8 字符串
            escape_to_html($row->Body);  
        }
        ?>
    </body>
</html>

4.5 扩展阅读

5. 国际化 (i18n) 与本地化 (l10n)

给新手的术语解释: i18nl10n 是缩略词。数字代表中间省略的字母数量。

  • Internationalization (国际化) 首尾之间有 18 个字母,缩写为 i18n。
  • Localization (本地化) 首尾之间有 10 个字母,缩写为 l10n。

首先,我们需要明确这两个相似但不同的概念及相关术语:

  • 国际化 (i18n): 是指你对代码进行组织和架构设计,使其能够适应不同语言或地区,而无需重构底层逻辑。这项工作通常只做一次(最好在项目初期),否则后期可能需要对源码进行伤筋动骨的修改!
  • 本地化 (l10n): 是基于前期 i18n 的基础,主要通过翻译内容来调整界面以适应特定地区。每次需要支持新语言时,或者新增了界面元素需要被翻译时,就需要进行本地化工作。
  • 复数化 (Pluralization): 定义了不同语言在处理包含数字和计数器的字符串时的规则。例如在英语中,“1个”是单数,其他的都是复数(通常加 S)。而在俄语、塞尔维亚语甚至阿拉伯语中,复数的形式多达 3 到 6 种。

5.1 常见的实现方式

最简单(但不推荐)的实现方式是使用包含翻译数组的 PHP 文件,直接在模板中调用,比如 <h1><?=$TRANS['title_about_page']?></h1>。对于任何严肃的项目来说,强烈不建议这么做,因为它会带来无穷的维护灾难(比如无法处理复杂的复数规则)。

业界最经典、常被奉为标杆的 i18n/l10n 方案是一个诞生于 1995 年的 Unix 工具:Gettext。它是一个非常成熟的软件翻译解决方案。它不仅易于运行,还拥有强大的辅助工具生态。

其他工具

有一些常用库支持 Gettext 和 i18n 的其他实现。其中一些看起来更容易安装或运行附加功能或 i18n 文件格式。在本文档中,我们专注于 PHP 核心提供的工具,但这里我们列出了其他工具以供完成:

  • aura/intl:提供国际化 (I18N) 工具,特别是面向包的 per-locale 消息翻译。它对消息使用数组格式。不提供消息提取器,但通过 intl 扩展(包括复数消息)提供高级消息格式。
  • 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)。

5.2 Gettext 结构与概念

5.2.1 文件类型

使用 Gettext 时,你主要与三种文件打交道:

  1. PO (Portable Object) 文件: 这是可读的文本文件,包含原文和翻译的列表(供人类翻译者编辑)。
  2. MO (Machine Object) 文件: 这是由 PO 文件编译而成的二进制文件。程序在运行时读取它,以实现极致的性能。
  3. POT (Template) 文件: 这是一个模板文件,包含了从你源码中提取出的所有待翻译的“键(原文)”。它是生成和更新所有 PO 文件的基础指南。 (每个语种都会有一对 PO/MO 文件,但整个项目或模块通常只有一个 POT 文件)

5.2.2 域 (Domains)

在大型项目中,同一个词在不同上下文中可能有不同的翻译。此时可以将翻译拆分到不同的域 (Domains) 中。说白了,域就是翻译文件的名字前缀。中小项目为了简单,通常只使用一个域(比如命名为 main)。

5.2.3 语言区域代码 (Locale code)

Locale 是标识语言版本的代码(基于 ISO 规范)。格式通常是两位的语言小写字母,加上下划线和两位的国家/地区大写字母。例如:

  • en_US (美国英语)
  • pt_BR (巴西葡萄牙语)
  • zh_CN (简体中文)

5.2.4 目录结构

使用 Gettext 需要遵循特定的文件夹结构。你需要创建一个存放语言包的根目录,里面为每个 locale 建一个文件夹,每个 locale 文件夹内必须有一个名为 LC_MESSAGES 的固定子目录,用来存放 PO/MO 文件:

<项目根目录>
 ├─ src/
 ├─ templates/
 └─ locales/
    ├─ main.pot  (模板文件)
    ├─ en_US/
    │  └─ LC_MESSAGES/
    │     ├─ main.mo
    │     └─ main.po
    ├─ zh_CN/
    │  └─ LC_MESSAGES/
    │     ├─ main.mo
    │     └─ main.po

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

在代码中调用翻译函数时,传入的“键 (key)”该怎么写?主要有两派:

  1. 使用真实的英文句子作为键 (推荐): 比如 gettext("Hello, User!")
    • 优点: 即使缺少对应语言的翻译,页面上也会显示有意义的英文,不至于完全没法看;翻译人员一眼就能看出上下文;默认就免费支持了英语。
    • 缺点: 如果英文原文修改了一个标点符号,所有语言文件中的对应键都需要跟着改。Gettext 官方手册推荐此方式。
  2. 使用结构化的唯一键: 比如 gettext("top_menu.welcome")
    • 优点: 逻辑和内容彻底分离,代码结构极其干净。
    • 缺点: 翻译人员如果没有上下文根本不知道怎么翻;如果漏翻了,界面上会直接显示丑陋的 top_menu.welcome 变量名。Symfony 等框架倾向于这种方式。

5.4 Gettext 日常开发示例

以下是一个将理论付诸实践的代码示例:

1. 模板文件中的调用方式

<?php include 'i18n_setup.php' ?>
<div id="header">
    <h1><?= sprintf(gettext('Welcome, %s!'), $name) ?></h1>
    
    <?php if ($unread): ?>
        <h2><?= sprintf(
            ngettext('Only one unread message', '%d unread messages', $unread),
            $unread
        ) ?></h2>
    <?php endif ?>
</div>

<h1><?= _('Introduction') ?></h1>
<p><?= _("We're now translating some strings") ?></p>

2. 初始化配置文件 (i18n_setup.php)

<?php
// ... 省略探测用户语言环境的代码,假设最终得到语言为 zh_CN ...
$lang = 'zh_CN'; 

// 设置系统环境变量
putenv("LANG=$lang");
// 设置 PHP 本地化环境 (如日期、货币格式等)
setlocale(LC_ALL, $lang);

// 绑定文本域 (Domain),告诉 Gettext 去哪里找 'main' 这个域的翻译文件
bindtextdomain('main', '../locales');
// 指定该域的编码为 UTF-8
bind_textdomain_codeset('main', 'UTF-8');

// 设置当前应用默认使用的文本域
textdomain('main');
?>

5.5 工具推荐:Poedit

手动编辑 PO 文件非常痛苦。强烈推荐使用免费的桌面应用 Poedit

它的核心流程非常顺畅:

  1. 在 Poedit 中新建项目,设置项目源码的路径。
  2. 点击 “从源代码中提取 (Extract from sources)”。
  3. Poedit 会自动扫描你项目里所有的 PHP 文件,找出所有写了 gettext()_() 的地方,并把英文原文提取成列表。
  4. 你在图形界面里挨个填写中文翻译。
  5. 点击保存,它会自动帮你把 main.po 编译成程序能读懂的 main.mo 文件。大功告成!

5.6 踩坑指南与技巧

  • 缓存噩梦 (Apache mod_php): 如果你使用 Apache 的 mod_php 模式,MO 文件会在第一次读取时被死死缓存在内存中。你更新了翻译文件后,经常需要重启 Apache 服务器才能看到效果。在 Nginx 或 PHP-FPM 环境下,这个问题通常不存在。
  • 自定义辅助函数: 觉得 gettext() 名字太长?除了内置的 _() 缩写,你完全可以自己写一个全局的 t() 函数。只需要在 Poedit 的“属性 -> 源关键字”配置中,把你自定义的函数名(如 t)加进去,它在扫描代码时就能准确识别并提取你的文本了。

6. 参考文献: