PHP7.0字符串在Docker怎么用

前言

同一段处理字符串的 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。做到这一点,代码在容器里和在宿主机上就会有一致的行为。

相关推荐
LeoCrawls1 小时前
Python 读取 JSON 常见报错排查,附完整处理函数
python·json·php
朝朝辞暮i2 小时前
C++ 第 10 课:函数 Function
开发语言·c++
朝朝辞暮i2 小时前
C++ 第7课 while 循环
开发语言·c++·算法
小朱爱编程1232 小时前
我用 Jev 做了三个实用工具:整理标签页、分诊飞书反馈、找回 GitHub 收藏
java·开发语言·人工智能·后端·python·架构·ai编程
谢亮_vipxieliang2 小时前
Java 8/11/17/21/25 怎么选?一篇讲清 LTS 升级路线
java·开发语言
Cindy_cd2 小时前
IDEA2021 配置 JDK+Maven
java·开发语言·maven
mmsx4 小时前
Android Dialog 插槽模式:用一套壳统一全应用的弹窗风格
android·java·dialog·开发语言
huanbailu4 小时前
Qt 跨线程信号槽:connect 返回 true 但槽函数不执行的坑
开发语言·c++·qt·信号槽·跨线程·元类型
ZealSinger4 小时前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go