PHP 开发实践
PHP 是一门广博的语言,它让各个水平的开发者都能快速、高效地产出代码。然而,在学习这门语言的过程中,为了图省事或因为养成了坏习惯,我们常常会遗忘(或忽视)最初学到的一些基础知识。
为了解决这个普遍存在的问题,本章旨在提醒开发者们牢记 PHP 中的基本编码实践。
1. 基础回顾
延伸阅读:
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 扩展阅读
- PHP 手册:字符串操作
- PHP 手册:字符串函数
- PHP 手册:多字节字符串函数
- mb_strpos()
- mb_strlen()
- mb_substr()
- mb_internal_encoding()
- mb_http_output()
- htmlentities()
- htmlspecialchars()
- Stack Overflow:什么原因导致 Unicode 不兼容?
- Stack Overflow:PHP 和 MySQL 国际化最佳实践
- MySQL 中如何完美支持 Unicode
- 使用简便的 UTF-8 将 Unicode 引入 PHP
- Stack Overflow: DOMDocument::loadHTML 未正确编码 UTF-8
5. 国际化 (i18n) 与本地化 (l10n)
给新手的术语解释: i18n 和 l10n 是缩略词。数字代表中间省略的字母数量。
- 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 时,你主要与三种文件打交道:
- PO (Portable Object) 文件: 这是可读的文本文件,包含原文和翻译的列表(供人类翻译者编辑)。
- MO (Machine Object) 文件: 这是由 PO 文件编译而成的二进制文件。程序在运行时读取它,以实现极致的性能。
- 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.po5.3 翻译键 (l10n keys) 的两种流派
在代码中调用翻译函数时,传入的“键 (key)”该怎么写?主要有两派:
- 使用真实的英文句子作为键 (推荐): 比如
gettext("Hello, User!")。 - 优点: 即使缺少对应语言的翻译,页面上也会显示有意义的英文,不至于完全没法看;翻译人员一眼就能看出上下文;默认就免费支持了英语。
- 缺点: 如果英文原文修改了一个标点符号,所有语言文件中的对应键都需要跟着改。Gettext 官方手册推荐此方式。
- 使用结构化的唯一键: 比如
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。
它的核心流程非常顺畅:
- 在 Poedit 中新建项目,设置项目源码的路径。
- 点击 “从源代码中提取 (Extract from sources)”。
- Poedit 会自动扫描你项目里所有的 PHP 文件,找出所有写了
gettext()或_()的地方,并把英文原文提取成列表。 - 你在图形界面里挨个填写中文翻译。
- 点击保存,它会自动帮你把
main.po编译成程序能读懂的main.mo文件。大功告成!
5.6 踩坑指南与技巧
- 缓存噩梦 (Apache mod_php): 如果你使用 Apache 的
mod_php模式,MO 文件会在第一次读取时被死死缓存在内存中。你更新了翻译文件后,经常需要重启 Apache 服务器才能看到效果。在 Nginx 或 PHP-FPM 环境下,这个问题通常不存在。 - 自定义辅助函数: 觉得
gettext()名字太长?除了内置的_()缩写,你完全可以自己写一个全局的t()函数。只需要在 Poedit 的“属性 -> 源关键字”配置中,把你自定义的函数名(如t)加进去,它在扫描代码时就能准确识别并提取你的文本了。