前言
同一段处理字符串的 PHP 代码,在开发机上跑得好好的,一进 Docker 容器就出问题------这是很典型的一类"环境差异"故障。常见的症状包括:
Fatal error: Call to undefined function mb_strlen(),本地明明能用;- 中文昵称在容器里截断后成了乱码,写库时报
Incorrect string value; json_encode()返回false,日志里只有一句"序列化失败",看不出哪里错;setlocale(LC_TIME, 'zh_CN.UTF-8')一直返回false,日期格式化永远是英文;- 用了
mysql_real_escape_string()的老代码在 PHP 7.0 上直接报"未定义函数"。
这些问题的根子不在 Docker,而在于 PHP 的"字符串"本质上是一串字节,而不是一串字符。本地环境恰好装了 mbstring 扩展、恰好生成了 locale、MySQL 恰好是 utf8mb4,于是所有隐含假设都被满足了;容器是一个干净的最小系统,这些假设一个都不成立,问题就全冒出来了。
本文以 PHP 7.0 在 Docker 中的运行为背景,讲清楚字节与字符的区别、容器里缺什么、以及怎样写出不依赖环境"恰好配好"的字符串处理代码。需要明确一点:PHP 7.0 的官方安全支持在 2019 年 1 月就已经结束,它只能用于维护老系统;本文的结论对 7.0 到 8.x 全都成立,新项目请直接使用 8.x。
一、PHP 的字符串是字节数组
$s = "中"; 这个变量里存的不是"一个汉字",而是 3 个字节:E4 B8 AD。所有"非 mb_ 前缀"的字符串函数都按字节工作:
| 函数 | 作用单位 | 对 "中文abc" 的结果 | 说明 |
|---|---|---|---|
strlen() |
字节 | 9 | 每个汉字 3 字节 |
substr($s, 0, 2) |
字节 | 半个汉字 | 得到非法 UTF-8 序列 |
mb_strlen($s, 'UTF-8') |
字符 | 5 | 需要 mbstring 扩展 |
mb_substr($s, 0, 2, 'UTF-8') |
字符 | 中文 |
需要 mbstring 扩展 |
preg_match('/^.$/u', '中') |
字符 | 1 | /u 修饰符打开 Unicode 模式 |
由此可以推出两个结论:
第一,按字节截断必然可能切碎多字节字符 。substr($str, 0, 10) 在英文上没问题,在中文上可能产出半截字符,这个非法字节序列随后会让 json_encode() 返回 false、让 MySQL 报错、让浏览器显示成问号------错误发生的地方离真正的原因非常远,这是此类问题最难查的地方。
第二,mysql_* 系列函数在 PHP 7.0 里已经被彻底移除 (它们是 PHP 5.5 起废弃、7.0 移除的)。如果你的老代码里还有 mysql_real_escape_string(),把镜像换成 php:7.0 之后会直接致命错误。这不是"字符串处理"的问题,但它是 PHP 7.0 迁移中最常见的字符串相关报错,必须一起改掉:换成 PDO 或 mysqli,并用预处理语句(prepared statement)代替手工转义。
二、容器里第一个会炸的是 mbstring
官方 PHP 镜像把扩展分成了两类:编译进去的,和需要用 docker-php-ext-install 现装的。哪些默认编译进去了,随镜像版本而变,唯一可靠的做法是先看容器自己的输出:
bash
docker run --rm php:7.0-cli php -m
这条命令会列出容器里真正加载的模块。如果输出的列表里没有 mbstring,那么任何 mb_* 函数都会致命错误。装它只需要两行:
bash
docker run --rm php:7.0-cli php -r "var_dump(function_exists('mb_strlen'));"
在 Dockerfile 里补上:
dockerfile
FROM php:7.0-cli
RUN docker-php-ext-install mbstring \
&& docker-php-ext-install pdo_mysql
docker-php-ext-install 会编译并自动启用扩展。这里有个容易忽略的点:不能只在开发时用 docker run 临时装,因为容器重启后安装就没了------扩展必须写在 Dockerfile 里,或者用 volumes 持久化,否则"昨天还好好的"会周期性复发。
如果环境不允许改动镜像,代码侧就必须有降级路径。好消息是,常见的字符串操作不一定要靠 mbstring:UTF-8 的编码规则是自描述的,遍历字节就能判断一个字符占几个字节。下面这个函数完全不依赖任何扩展:
php
<?php
declare(strict_types=1);
// PHP 7.0+
/**
* 判断 UTF-8 字符的首字节后面还跟几个续接字节
* 续接字节形如 10xxxxxx
*/
function utf8CharLen(int $byte): int
{
if ($byte < 0x80) { // 0xxxxxxx ASCII
return 1;
}
if (($byte & 0xE0) === 0xC0) { // 110xxxxx 2 字节
return 2;
}
if (($byte & 0xF0) === 0xE0) { // 1110xxxx 3 字节(汉字在这里)
return 3;
}
if (($byte & 0xF8) === 0xF0) { // 11110xxx 4 字节(emoji)
return 4;
}
return 1; // 非法首字节,按 1 字节跳过,保证不会死循环
}
/** 按"字符"截断,绝不切碎多字节字符 */
function utf8Cut(string $s, int $maxChars): string
{
$len = strlen($s);
$i = 0;
$chars = 0;
while ($i < $len && $chars < $maxChars) {
$step = utf8CharLen(ord($s[$i]));
if ($i + $step > $len) {
break; // 末尾残留半个字符,直接丢掉
}
$i += $step;
$chars++;
}
return substr($s, 0, $i);
}
这段代码的价值不只是"能用":它也解释了 mb_substr 内部在做什么。容器里装了 mbstring 就优先用 mb_substr(),没装就用这个兜底,行为可控。
三、locale 与数据库字符集:容器和宿主机不一样的地方
locale 在容器里通常是空的。 最小镜像只带 C 和 POSIX,所以下面这行在容器里返回 false:
php
var_dump(setlocale(LC_ALL, 'zh_CN.UTF-8')); // bool(false)
它带来的实际影响是:依赖 locale 的格式化函数拿不到中文结果。想让容器支持,需要在构建时生成 locale:
dockerfile
RUN apt-get update \
&& apt-get install -y locales \
&& locale-gen zh_CN.UTF-8 \
&& rm -rf /var/lib/apt/lists/*
bash
# 验证容器里到底有哪些 locale
docker run --rm php:7.0-cli locale -a
数据库字符集则是另一个高频坑。 utf8 在 MySQL 里最多存 3 字节,emoji(4 字节)会直接报 Incorrect string value。连接时显式指定 utf8mb4 是唯一稳妥的做法:
php
$pdo = new PDO(
'mysql:host=db;dbname=app;charset=utf8mb4',
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
注意 charset=utf8mb4 写在 DSN 里比事后执行 SET NAMES utf8mb4 更可靠,因为它在连接建立时就已经生效。
四、完整可运行示例
把上面的要点串成一个可以直接跑的脚本,保存为 str_demo.php,用官方镜像运行:
bash
docker run --rm -v "$PWD":/app -w /app php:7.0-cli php str_demo.php
php
<?php
declare(strict_types=1);
// 运行环境:PHP 7.0+(本文所有函数在 7.0-8.x 上行为一致)
// ---------- 1. 环境体检 ----------
printf("PHP 版本 : %s\n", PHP_VERSION);
printf("mbstring : %s\n", extension_loaded('mbstring') ? '已加载' : '未加载');
printf("iconv : %s\n", extension_loaded('iconv') ? '已加载' : '未加载');
printf("default_charset: %s\n", ini_get('default_charset'));
printf("locale(zh_CN) : %s\n", var_export(setlocale(LC_ALL, 'zh_CN.UTF-8'), true));
// mbstring 在 7.0 上不一定存在,存在时显式统一内部编码
if (extension_loaded('mbstring')) {
mb_internal_encoding('UTF-8');
}
// ---------- 2. 字节 vs 字符 ----------
$s = '中文abc';
printf("strlen=%d 字符数=%d\n", strlen($s), str_char_count($s));
// ---------- 3. 安全截断(不依赖 mbstring) ----------
$name = '张三丰是个很长很长的名字';
printf("前 3 个字符: %s\n", utf8Cut($name, 3));
printf("字节截断的后 20 字节能否 json_encode: %s\n",
json_encode(substr($name, 0, 20)) === false ? '否(非法 UTF-8)' : '是');
printf("字符截断后能否 json_encode: %s\n",
json_encode(utf8Cut($name, 4), JSON_UNESCAPED_UNICODE) === false ? '否' : '是');
// ---------- 4. 正则的 /u 修饰符 ----------
$broken = substr($name, 0, 20); // 故意切碎一个汉字
$m1 = preg_match('/^./u', $broken, $match);
printf("preg_match(/u) 返回: %s preg_last_error=%d\n",
var_export($m1, true), preg_last_error());
// ---------- 5. JSON 失败要查错因 ----------
$json = json_encode(['name' => $broken], JSON_UNESCAPED_UNICODE);
if ($json === false) {
printf("json_encode 失败: %s\n", json_last_error_msg());
}
// ---------- 6. 函数定义放在最后,与上面的调用无关 ----------
function str_char_count(string $s): int
{
if (extension_loaded('mbstring')) {
return mb_strlen($s, 'UTF-8');
}
$len = strlen($s);
$i = 0;
$chars = 0;
while ($i < $len) {
$step = utf8CharLen(ord($s[$i]));
if ($i + $step > $len) {
$chars++; // 末尾半个字符也算一个,避免结果忽大忽小
break;
}
$i += $step;
$chars++;
}
return $chars;
}
function utf8CharLen(int $byte): int
{
if ($byte < 0x80) {
return 1;
}
if (($byte & 0xE0) === 0xC0) {
return 2;
}
if (($byte & 0xF0) === 0xE0) {
return 3;
}
if (($byte & 0xF8) === 0xF0) {
return 4;
}
return 1;
}
function utf8Cut(string $s, int $maxChars): string
{
$len = strlen($s);
$i = 0;
$chars = 0;
while ($i < $len && $chars < $maxChars) {
$step = utf8CharLen(ord($s[$i]));
if ($i + $step > $len) {
break;
}
$i += $step;
$chars++;
}
return substr($s, 0, $i);
}
在容器里跑出来的结果(text,仅示意格式,具体数值随镜像而变):
text
PHP 版本 : 7.0.33
mbstring : 未加载
iconv : 已加载
default_charset: UTF-8
locale(zh_CN) : false
strlen=9 字符数=5
前 3 个字符: 张三丰
字节截断的后 20 字节能否 json_encode: 否(非法 UTF-8)
字符截断后能否 json_encode: 是
preg_match(/u) 返回: false preg_last_error=4
json_encode 失败: Malformed UTF-8 characters, possibly incorrectly encoded
两个最值得记住的输出是最后三行:preg_match() 在 /u 模式下遇到非法 UTF-8 返回的是 false 而不是 0 ,而 false 在 if 里和 0 一样为假,很多人因此把"数据坏了"误判成"没匹配上";preg_last_error() 返回的非 0 值才是真正的信号。
常见坑点
1. 只按字节判断长度
❌ if (strlen($nickname) > 20) { ... }------中文用户 7 个字就被判超长 ✅ mb_strlen($nickname, 'UTF-8'),或用上面不依赖扩展的字符计数
2. 用 substr 截断多字节字符串
❌ $title = substr($title, 0, 30); 可能切出半个汉字 ✅ 优先 mb_substr();没有 mbstring 时用按 UTF-8 首字节判断的自实现版本
3. 把 preg_match 的返回值和 false 混为一谈
❌ if (!preg_match('/^[\x{4e00}-\x{9fa5}]+$/u', $name)) { 视为非法输入 } ✅ 先判断 preg_last_error() !== PREG_NO_ERROR,区分"不匹配"和"数据非法"
4. 忽略 json_encode 的返回值
❌ $body = json_encode($data); 然后直接把 false 发给下游 ✅ 检查 === false 并打印 json_last_error_msg(),加 JSON_UNESCAPED_UNICODE 让中文可读
5. 用 MySQL 的 utf8 存 emoji
❌ DSN 里写 charset=utf8,用户输入 emoji 时插入报 Incorrect string value ✅ 用 charset=utf8mb4,并确认表的字符集也是 utf8mb4
6. 以为本地有 mbstring 容器就有
❌ 从不检查 php -m,上线才发现 mb_strlen 未定义 ✅ 构建阶段检查扩展,或在代码里对扩展可用性做降级判断
7. 临时 docker exec 装扩展
❌ 在运行中的容器里 docker-php-ext-install mbstring,重建容器后扩展消失 ✅ 扩展写进 Dockerfile,一次构建到处运行
8. setlocale 失败后继续用
❌ setlocale(LC_ALL, 'zh_CN.UTF-8'); echo strftime('%B'); 静默输出英文 ✅ 检查 setlocale() 的返回值;容器里改用与 locale 无关的时间格式化函数
9. 还在用 mysql_*
❌ mysql_real_escape_string($s)------PHP 7.0 起这些函数已被移除 ✅ 换 PDO 或 mysqli,用预处理语句传参,不做手工转义
总结
| 问题 | 容器里的表现 | 处理方式 |
|---|---|---|
| mbstring 缺失 | Call to undefined function mb_* |
Dockerfile 里 docker-php-ext-install mbstring |
| 字节截断 | 乱码、Incorrect string value |
按字符截断,或自实现 UTF-8 安全截断 |
| locale 为空 | setlocale() 返回 false |
镜像里 locale-gen,或改用与 locale 无关的函数 |
| 非法 UTF-8 | json_encode 返回 false、preg_match 返回 false |
检查 json_last_error() / preg_last_error() |
| 数据库字符集 | emoji 存不进去 | DSN 写 charset=utf8mb4 |
| 老 API | mysql_* 致命错误 |
迁移到 PDO / mysqli + 预处理 |
容器不背这个锅:它只是把"页面字符集、扩展、locale、数据库字符集"这几层隐含假设全部剥掉了。与其在每个函数前面手忙脚乱地加 mb_,不如先把这条链路固定成一条明确规则------输入一律视为字节流,边界处才谈字符,所有截断和计数都按字符做,所有编码转换都显式指定 UTF-8。做到这一点,代码在容器里和在宿主机上就会有一致的行为。