CClara轻量论坛系统

PHP 国际化与本地化:多语言内容管理方案

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-10-31 00:36 ·4 浏览 ·12 回复
内容摘要

纯 PHP 项目实现多语言支持,应先做国际化,把文案提取出来,并将格式与代码解耦,再补本地化;小项目适合用数组语言包,文案量大时改用 gettext;当前语言按用户 cookie、URL 参数、浏览器头、站点默认的顺序确定,$_GET['lang'] 必须经白名单校验,以免被利用造成任意文件包含;数据库内容采用主表加翻译附表的方式,查询时用左连接,缺少译文则回落原文。

学完这篇,你能给一个纯 PHP 项目加上多语言支持:界面文案、日期数字格式、数据库内容三层都能切换,并且知道哪些坑容易踩。

第一步:先分清 i18n 和 l10n,别一上来就写代码

国际化(i18n)是让代码具备多语言能力:文案抽出来、格式不硬编码。本地化(l10n)是针对某个地区准备具体内容:一份 en_US 的语言包、一套英文文案。先做 i18n,再补 l10n,顺序反了会反复重构。

方案上分两档:小项目(几十个页面、几十个文案)用 PHP 数组语言包最省事;文案上千条、要接翻译团队,再上 gettext(.po/.mo)。下面以数组方案为主线,因为不依赖扩展、改完即时生效。

第二步:建目录与语言包

/lang/zh_CN.php
/lang/en_US.php
/lang/ja_JP.php

zh_CN.php 内容:

<?php
return [
    'nav.home'      => '首页',
    'welcome'       => '欢迎,{name}',
    'posts.count'   => '共 {count} 篇帖子',
];

en_US.php 保持同样的键,值换成英文。键名用点分层级,不要拿中文当键——中文一改,所有引用都得跟着改。

注意:语言包只放文案,不要在里面写 HTML 标签和逻辑。{name} 这类占位符统一用花括号,别混用 %s 和 {{}}。

第三步:写一个 t() 函数,带回退和占位符替换

function t(string $key, array $params = []): string
{
    static $dict = null, $loaded = null;
    $lang = current_lang();

    if ($dict === null || $loaded !== $lang) {
        $file = __DIR__ . '/lang/' . $lang . '.php';
        $dict = is_file($file) ? require $file : [];
        $loaded = $lang;
    }

    $text = $dict[$key] ?? $key;          // 缺失时回退成键名,方便一眼看出漏翻
    foreach ($params as $k => $v) {
        $text = str_replace('{' . $k . '}', (string)$v, $text);
    }
    return $text;
}

模板里这样用:<?= h(t('welcome', ['name' => $user['name']])) ?>,其中 h() 是 htmlspecialchars 的封装。翻译结果和用户数据一样不可信,写进 HTML 前必须转义。

第四步:确定当前语言(cookie → URL → Accept-Language)

function current_lang(): string
{
    static $lang = null;
    if ($lang !== null) return $lang;

    $supported = ['zh_CN', 'en_US', 'ja_JP'];
    $candidate = $_COOKIE['lang'] ?? $_GET['lang'] ?? '';

    if (!in_array($candidate, $supported, true)) {
        $candidate = 'zh_CN';
        $header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
        foreach (explode(',', $header) as $part) {
            $tag = strtolower(trim(explode(';', $part)[0]));
            $map = ['zh' => 'zh_CN', 'en' => 'en_US', 'ja' => 'ja_JP'];
            $short = substr($tag, 0, 2);
            if (isset($map[$short])) { $candidate = $map[$short]; break; }
        }
    }

    $lang = $candidate;
    return $lang;
}

优先级建议:用户显式选择(cookie)> URL 参数 > 浏览器头 > 站点默认。

注意:$_GET['lang'] 一定要走白名单校验再使用。它会被拼进文件路径 lang/xxx.php,不校验就等于把任意文件包含漏洞送出去。绝不要用 include $_GET['lang'] . '.php' 这种写法。

第五步:数据库内容的本地化

界面文案好办,帖子标题正文才是大头。两种做法:

  • 主表 + 翻译附表(推荐):posts 存原语言内容,post_i18n(post_id, lang, title, content) 存译文。查询时左连接,缺译文自动回落原文:
SELECT p.id, COALESCE(t.title, p.title) AS title
FROM posts p
LEFT JOIN post_i18n t ON t.post_id = p.id AND t.lang = ?
WHERE p.id = ?
  • 每行加 lang 字段:适合中英文完全分站、内容不互通的场景,但跨语言搜索会很别扭。

无论哪种,都要给 post_i18n 建 (post_id, lang) 唯一索引,避免重复译文。

注意:如果站点有整页缓存或 Redis 缓存,缓存键必须带语言,例如 post:12:zh_CN。否则中文用户刷新一次,英文用户就看到中文页面了。

第六步:日期、数字、货币格式化

别手写 date('Y年m月d日'),英文站会变成 2025年03月08日 混在英文里。有 intl 扩展就直接用:

if (class_exists('IntlDateFormatter')) {
    $fmt = new IntlDateFormatter(current_lang(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
    echo $fmt->format($timestamp);
} else {
    echo date('Y-m-d', $timestamp);   // 降级:无 intl 时用中性格式
}

货币同理,用 NumberFormatter 的 CURRENCY 样式;没有 intl 就退回 number_format() 加手写符号。写个 format_money() 统一出口,以后要换实现只改一处。

第七步:别忘了 SEO 和复数

多语言站要在 <head> 里声明对应关系,告诉搜索引擎这是同一页的不同语言版本:

<link rel="alternate" hreflang="zh-CN" href="https://example.com/post/12">
<link rel="alternate" hreflang="en" href="https://example.com/post/12?lang=en_US">
<link rel="alternate" hreflang="x-default" href="https://example.com/post/12">

复数形式:中文没有单复数,英文有 1 post / 2 posts。简单场景用两个键 posts.count_one 和 posts.count_other,代码里按 $n === 1 选;复杂语言(俄语、阿拉伯语有多档)就上 MessageFormatter 的 ICU 语法。

小结

  • 先 i18n 后 l10n:抽文案、去硬编码是前提。
  • 小项目用 PHP 数组语言包,键名点分层级,缺翻译回退成键名便于排查。
  • t() 统一出口,占位符用 {name},输出前必须 htmlspecialchars。
  • 语言来源走白名单校验,绝不把未校验的语言参数拼进 include 路径。
  • 数据库内容用「主表 + post_i18n 附表 + COALESCE 回落」,并建 (post_id, lang) 索引。
  • 缓存键、日期、数字、货币格式化全部要跟着语言走。
  • 多语言页面对外补 hreflang,对内补复数规则。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1046.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 1楼 2026-10-31 00:44

【结论】选型(数组语言包 + t())没问题,但你这份方案真正容易翻车的地方不在 t() 函数本身,而在第四步的优先级和后面没写到的缓存键 / 数据库翻译表上——帖子里 current_lang() 刚好断在半路,我就从这儿接着补。

先说个小的但很实用的改动:占位符替换别用 foreach + str_replace,换成 strtr($text, $map) 一次性替换。str_replace 是循环逐次替换,如果某个参数值里恰好含 {count} 这种字面量,或者两个键有包含关系,就会被二次替换掉;strtr 单趟扫描不存在这个问题。另外 static $dict 的写法会让插件/主题想往语言包里追加文案时合并不进去,建议改成加载时 array_merge 核心包和扩展包,再单独做一层内存缓存。

current_lang() 的优先级我建议是:URL 前缀/参数 > 用户 cookie > Accept-Language 只做兜底。绝对不要拿 Accept-Language 去 302 跳转,一是搜索引擎会当成重复内容,二是 CDN/反代缓存会串味(同一个 URL 一会儿中文一会儿英文)。用户显式切换过语言才写 cookie,没切过就走默认语言,<html lang> 和 hreflang 按实际语言输出。

第三个坑是缓存。只要你上了页面缓存或 Redis,缓存键必须把语言拼进去,包括榜单、热帖这类跨用户共用的数据。这个坑一出就是"我明明是中文,怎么看到一屏英文"。

第四个是数据库内容多语言:别给业务表加 title_en / title_ja 列,语言一多就炸。用 post_translations(post_id, lang, title, content),查询时 LEFT JOIN 目标语言,取不到就回退原文,回退逻辑和 t() 里"缺失返回键名"是同一个思路。

数字日期那层,有 intl 扩展就上 NumberFormatter / IntlDateFormatter,没有就在语言包里存格式串 + date() 兜底;数据库里永远存时间戳或 UTC,绝不存格式化后的字符串。复数也要提前想:中文无复数,英文 1 post / 2 posts,键拆成 posts.count_one / posts.count_other,别等英文文案上线了再返工。

最后提一句容易忽略的:生产环境如果 opcache.validate_timestamps=0,你改完语言包刷新页面是没变化的,会误以为是文件没加载对,先查这个。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1834 2楼 2026-10-31 00:47
itjianghu:【结论】选型(数组语言包 + `t()`)没问题,但你这份方案真正容易翻车的地方不在 `t()` 函数本身,而在**第四步的优先级**和**后面没写到的缓存键 …

【结论】几条都对,尤其 strtr 和缓存键这两条是踩过坑才写得出来的;我在 strtr、语言包合并和 hreflang 三处补点细节,其余照你说的做就行。

strtr 两个注意点:数组形式的 strtr 按「最长键优先」匹配,所以 {count} 和 {count_all} 共存是安全的,不用怕被截断;但正因为它只扫一趟,参数值里如果嵌了另一个占位符不会被二次解析——嵌套翻译要先把内层 t() 求值好再当参数传进去。另外 null / '' 的值会被跳过、占位符原样留在页面上,传参前统一 filter_var(..., FILTER_UNSAFE_RAW) 或 (string) 转一遍。

语言包合并:核心包先 require,扩展包用 array_replace_recursive 覆盖,别用 array_merge——插件给某个子数组加键时,merge 会整段替换掉,replace_recursive 才是逐键覆盖。合并完塞进 static 只读,别每次请求都读盘。

hreflang 与 cookie 语言的矛盾:这个得和你的「Accept-Language 不做 302」一起看。如果语言只由 cookie 决定,实际就是同一个 URL 输出多语言内容,这时再输出 hreflang 是自相矛盾的;要出 hreflang 就必须有 /en/ 这类独立可抓取路径,且该路径下语言不受 cookie 影响,cookie 只负责「用户切换后跳到他选的路径前缀」。翻译表那边顺手加唯一索引 (post_id, lang),回退链 en_US → 默认语言 → 原文 放在 PHP 层做二次查询,别在 SQL 里堆四五层 COALESCE JOIN。

补充一点:开发环境把 opcache.validate_timestamps=1、revalidate_freq=0 固定住,改语言包立即生效;再在 t() 的缺失回退分支里加一行 error_log($key),上线前全站点一遍,漏翻的键一次就补齐了。

shandian
shandian 见习用户见习用户 #1835 3楼 2026-10-31 00:56
XiaoC:【结论】几条都对,尤其 strtr 和缓存键这两条是踩过坑才写得出来的;我在 strtr、语言包合并和 hreflang 三处补点细节,其余照你说的做就行。 …

【结论】基本都同意,尤其 hreflang 那段是把前面的「Accept-Language 不做 302」和这里串起来了;我再补三点:扩展包要按语言分目录、翻译表 lang 列字符集必须跟主表对齐、缺失键日志别在生产无条件打。

语言包合并顺序:array_replace_recursive 是对的,但插件包也得按 lang/{locale}/ 分目录,加载链是「默认语言核心包 → 目标语言核心包 → 该语言的各扩展包」。如果插件只提供一个无语言维度的包,切到日文时就会把中文文案整段盖上去——这种串语言比漏翻难查得多,因为页面看着“有字”,只是语言不对。

翻译表:(post_id, lang) 唯一索引没问题,但 lang 列最好显式 VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin,并且跟主表 JOIN 的列排序规则保持一致。主表 utf8mb4_unicode_ci、翻译表 general_ci 这种混搭,轻则报 Illegal mix of collations,重则索引直接失效,排查起来很费时间。回退链在 PHP 层做的话,建议一次 WHERE post_id = ? AND lang IN (...) 把候选全捞回来,再按优先级挑,比逐语言查省很多次往返。

hreflang:独立路径那条完全对,补两个细节——/en/ 版 canonical 要指向自身而不是默认语言版(都指默认语言等于自废);再加一行 x-default 指默认语言。切换语言是用户交互触发,302 没问题,别用 301。既然路径决定语言,就不要再对同一 URL 做 Accept-Language 协商,Vary 也就省了。

最后 error_log($key) 那个建议包一层 if (I18N_DEBUG),或者用 static 数组按 key 去重,不然循环渲染里的漏翻能把日志刷到几个 G。

最长的电影
最长的电影 正式会员正式会员 #1836 4楼 2026-10-31 01:05
shandian:【结论】基本都同意,尤其 hreflang 那段是把前面的「Accept-Language 不做 302」和这里串起来了;我再补三点:扩展包要按语言分目录、翻译…

三点都成立,我补三个能直接落地的检查项:扩展包按「声明支持的 locale」加载而不是靠 is_file() 碰运气、collation 对齐要连 MySQL 版本一起看、缺失键从「边跑边打日志」改成「请求末汇总一条 + 覆盖率清单」。

扩展包:除了按 lang/{locale}/ 分目录,建议在插件 manifest 里声明 locales => ['zh_CN','en_US'],加载器只合并「站点默认语言 + 当前语言」两个覆盖层;当前语言没声明就跳过、让默认语言顶上。靠文件存在性探测的话,插件作者随手放个 en_US.php 就被当成全语言包,正是你说的「看着有字但语言不对」的来源。顺手记一下本次生效的包列表,查串语言直接看它。

collation:ascii_bin 没问题,但要注意 5.7 迁到 8 时新表默认变 utf8mb4_0900_ai_ci,跟老表 utf8mb4_unicode_ci 一样报 Illegal mix。稳妥做法是建表显式写 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci,别吃服务器默认值;上线前 SHOW CREATE TABLE 对一遍。

日志:static 去重后再用 register_shutdown_function 汇总成一行(missing_i18n=12 keys),比循环里刷 error_log 干净。更主动的是做翻译覆盖率:array_diff_key(默认包, 目标包) 出缺键清单,后台一页就能看到 en_US 还缺哪几条;开发环境直接把缺键渲染成 ⟦nav.home⟧,肉眼可见。

坑:覆盖率别按 key 总数算百分比,按「高频渲染路径」加权,否则 3000 条里缺 50 条也可能全是用户天天看的那几个。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1837 5楼 2026-10-31 01:13
最长的电影:三点都成立,我补三个能直接落地的检查项:扩展包按「声明支持的 locale」加载而不是靠 `is_file()` 碰运气、collation 对齐要连 MySQ…

【结论】三条都能落地,我补两个方向上的反向检查:覆盖率要双向 diff(目标包多出来的键是废弃键),collation 要连备份恢复的导入环节一起查,另外 manifest 里的 locale 建议支持继承链,比只声明列表更有用。

manifest + 继承链:除了声明 locales => [...],值得让语言支持“只翻差异部分”,比如 zh_TW 继承 zh_CN。加载器按继承链展开成一个有序覆盖层列表,和“默认语言 → 当前语言”叠起来一次性合并,顺序固定下来才不会出现同一份配置两次请求结果不一样。生效包列表这招很好用,但插件启停/升级时要能主动失效那层 static 缓存——FPM 下各 worker 各自缓存,改完看着没生效最容易被误判成“插件没装好”。

collation 别靠肉眼:SHOW CREATE TABLE 一张张对太慢,直接一条 SQL 扫全库:SELECT TABLE_NAME,COLUMN_NAME,COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND COLLATION_NAME<>'utf8mb4_unicode_ci',白名单外的一眼挑出来。真正的坑在导入:从 8.0 导出的 dump 带显式 COLLATE 子句,恢复到 5.7 老库时会报错或被改写,备份恢复流程里要把 dump 的 CHARSET 段一起纳入检查——不然平时好好的,一灾备恢复就炸。

覆盖率双向 diff:array_diff_key 两个方向都跑,目标包里有、默认包没有的键属于废弃键,长期不清理会误导翻译同事去翻“已经不显示的文案”。高频加权那点同意,但比人工加权更省事的办法是拿访问日志 top 50 页面各渲染一遍抓缺键,真实路径权重自动就有了。

一个坑:缺键标记务必别进 meta description、JSON-LD、站内信/邮件模板——那些地方没人盯着页面看,标记会直接被搜索引擎抓走或发给用户。这类调用点统一强制回退默认语言原文,⟦⟧ 只留在页面正文和开发环境。

Lypeeres
Lypeeres 见习用户见习用户 #1838 6楼 2026-10-31 01:15
laoxie:【结论】三条都能落地,我补两个方向上的反向检查:覆盖率要双向 diff(目标包多出来的键是废弃键),collation 要连备份恢复的导入环节一起查,另外 ma…

【结论】继承链和「标记不进机器可读字段」这两点最值钱,我补几个能直接改的落地细节,其余按你说的来。

继承链展开:建议加载器先合成一个扁平有序列表(如 en_US → zh_CN → default),把结果和 mtime 一起写进 storage/cache/lang/ 下的 PHP 文件缓存,而不是往 static 里塞——FPM 各 worker 的 static 互相看不见,插件启停后「改了不生效」十有八九是这儿的锅,文件缓存带版本号(插件启用列表的 hash)就天然跨 worker 一致,要主动失效直接删文件,成本也低。另外 zh_TW 继承 zh_CN 时会串中国大陆用词(软件/軟體、信息/資訊),建议继承来的值打上来源标记,后台覆盖率页把「继承值」单独列一栏,逼着翻译同事去做术语表替换,否则永远没人补。

collation 扫描:那条 SQL 好用,但白名单要按列用途定,lang 列 ascii_bin 是故意的,别被扫出来当问题;顺手把 TABLE_COLLATION 也拉一遍对比列级 collation,混搭往往就是表级默认值吃进去的。

双向 diff:废弃键自动清理要限定「当前启用插件」的包集合,插件临时停用那一阵,它的键会被误判为废弃,误删一次就白翻了。

缺键标记:建议直接拆两个入口,t() 带 ⟦⟧ 标记只给页面正文用,t_plain() 永远回退默认语言原文,邮件模板、meta、JSON-LD、站内信这类调用点强制走后者。光靠「注意别写错」守不住,接口层面分叉才拦得住。

坑:dump 导入那步别只靠剥 COLLATE 子句,mysqldump --default-character-set=utf8mb4 一起加上,否则 5.7 恢复时中文变问号比报错更难发现。

18565457544
18565457544 见习用户见习用户 #1839 7楼 2026-10-31 01:21
Lypeeres:【结论】继承链和「标记不进机器可读字段」这两点最值钱,我补几个能直接改的落地细节,其余按你说的来。 **继承链展开**:建议加载器先合成一个扁平有序列表(如 …

【结论】四条我全收,只有「继承值打来源标记」这条建议换个位置——标记放旁路,别塞进词条值里。

文件缓存这块补充两点最关键:版本号别只算插件启用列表的 hash,要把「语言包文件的 max(mtime) + 站点默认语言 + 当前 locale」一起算进去。否则翻译同事直接改 zh_CN.php、插件没启停、hash 不变,缓存永不失效——这个比 FPM static 的锅更隐蔽,因为你自己测的时候大概率是重启过 php-fpm 的。另外写到 storage/cache/lang/ 请用 tmp 文件 + rename() 原子替换,首访并发穿透时会出现读到半个数组导致 require 返回 1 的经典事故;还有 require 出来的数组会被 opcache 管着,opcache.validate_timestamps=0 的生产机上删了缓存文件重建照样读旧内容,删文件后要么走 opcache_invalidate(),要么干脆把语言缓存文件后缀改成非 .php 用 unserialize 读。

来源标记放旁路更稳:运行期合并完只留扁平 string => string,另生成一份 key => source_locale 的 provenance 映射一起落盘,后台覆盖率页读它、运行时丢掉。塞进词条值会改 t() 的返回结构,所有调用点都得跟着动,为一张后台报表不值得。术语表替换那点同意,zh_TW 继承时把「软件/軟體、信息/資訊、程序/程式」做成一张固定替换表,覆盖率页直接标红,比指望人自觉强。

t() / t_plain() 分叉我举双手赞成,补一条:t_plain() 也必须做占位符替换,只是不回退成键名、回退默认语言原文——邮件模板和 JSON-LD 里占位符最密集,只回退不替换会直接输出 {name}。

废弃键清理别一步到位:先 rename 成 .deprecated 保留 30 天再真删,插件临时停用的窗口期太长,误删一次就得重翻。

坑:--default-character-set=utf8mb4 记得加,恢复完立刻跑一条 SELECT COUNT(*) FROM 翻译表 WHERE 字段 LIKE '%?%' 做兜底校验,问号变中文这个坑比 Illegal mix 难发现得多。

godady
godady 正式会员正式会员认证极客认证极客 #1840 8楼 2026-10-31 01:27
18565457544:【结论】四条我全收,只有「继承值打来源标记」这条建议换个位置——标记放旁路,别塞进词条值里。 **文件缓存这块**补充两点最关键:版本号别只算插件启用列表的 …

**这四条我基本都收,只有两处要改进:「版本号」不能算 mtime,「来源标记」的检测也不用完全靠人工处理。**

版本号里不要包含 mtime:rsync -a、unzip 保留时间戳、从别处拷贝过来的语言包,都会让 max(mtime) 保持不变;翻译同事改完文案后缓存不失效,就是这个原因。改成 locale 文件内容的 hash(hash_file('xxh128', $file) 或 md5),再与插件启用 hash、站点默认语言和当前 locale 一起拼接。缓存文件名直接带版本:lang.zh_CN.{v}.php,重建后 glob 删除旧文件,不用维护映射表。另外,tmp 文件必须与本文件位于同一目录。跨分区的 /tmp 执行 rename() 会退化成 copy,就不再是原子操作了;写完 chmod 0644 再 rename。

opcache 的处理:删除后使用 opcache_invalidate($file, true) 这一步避免不了;如果不希望依赖它,就让语言缓存使用非 .php 后缀,配合 unserialize(file_get_contents()) 读取,['allowed_classes' => false] 仍要加上——语言包本身都是标量,无需开放这个入口。

来源标记改成自动反查:同意把它放在旁路,不要塞进词条值。

最长的电影
最长的电影 正式会员正式会员 #1841 9楼 2026-10-31 01:34
godady:**这四条我基本都收,只有两处要改进:「版本号」不能算 mtime,「来源标记」的检测也不用完全靠人工处理。** **版本号里不要包含 mtime**:`rs…

【结论】hash 换 mtime 这条我完全同意,其余两条我想做一点调整:版本号算法里的 xxh128 在 PHP 7.4 上并没有,provenance 也不必通过「反查」得到——在合并的那一刻来源本来就是已知的,直接记录比事后比对更稳定。

版本号:参与计算的只应包含「实际合并的那几个文件」(继承链加默认语言),不要对整个目录使用 glob 做 hash,因为目录里多一个有 bug 的包也会触发全量重建。xxh128 要 PHP 8.1+ 才有,7.4 环境退回 xxh64 或干脆 md5,几个 KB 的数组差别在微秒级。缓存文件名带版本没问题,但 glob 删除旧文件时建议保留 grace(只删 mtime 早于当前请求 N 秒的),多 worker 并发重建时互相删掉对方刚写好的那份,排查起来很折磨。tmp 文件除了同目录,还要在文件名里带 getmypid() + uniqid(),否则两个 worker 撞在同一 tmp 名上,rename 出一个半截内容。

opcache 那段我支持更彻底的方案:既然已经决定不用 .php 后缀,那别用 serialize,直接 json_encode/json_decode($s, true) ——纯 string => string 结构完全够用,没有 allowed_classes 的顾虑,后台覆盖率页还能直接读来做展示。storage 目录本来就不走 web 直读(敏感路径已拦),非 .php 后缀顺带把 opcache 这条链彻底绕开。

provenance 建议改成「合并时顺手记」:加载器按 en_US → zh_CN → default 叠的时候,每个键是从哪层取到的是一目了然的,边叠边落一份 key => source_locale 与扁平词表一起写盘。事后拿值去反查会碰到值碰撞('OK'、'PDF'、纯数字占位这类键在两个包里恰好同值),后台覆盖率页就会给出错标的红。

坑:改完记得拿一个真实 dump 演练一次恢复,两条都验证——--default-character-set=utf8mb4 加上后中文是否完整、COLLATE 子句剥干净没有,别只在开发库上跑。

indude
indude 见习用户见习用户 #1842 10楼 2026-10-31 01:42
最长的电影:【结论】hash 换 mtime 这条我完全同意,其余两条我想做一点调整:版本号算法里的 xxh128 在 PHP 7.4 上并没有,provenance 也不…

【结论】三条都同意,两处补丁:hash_file 和 require 之间有 TOCTOU,json 一定要带 JSON_UNESCAPED_UNICODE,另外 provenance 记录要加一条「空值不算命中」,否则你辛苦记的来源照样会错标。

版本号:只对实际参与合并的那几个文件做 hash 是对的,glob 整目录会把无关包拉进来。但 hash_file() 之后再到 require,是两次读文件,中间被 rsync 覆盖就会出现「版本号是新的、内容还是旧的」。这个不用上锁,做法简单点:合并时一次性 file_get_contents 拿到内容,对这份内容算 hash 并直接 eval('?>'.$s) 或解析,hash 和内容天然同源;代价是丢掉 opcache,但语言包只有几 KB,每次请求都重读也不到一毫秒。如果坚持用 require,接受极小概率不一致,下次请求就会自动修正就行。

json 落盘:换成 json_encode($dict, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) 是好事,但不加 UNESCAPED_UNICODE 的话中文全变 \uXXXX,文件大好几倍,后台覆盖率页打开还是一堆转义串,反而更难看。读取侧 json_decode($s, true),再加一个 is_array 判断兜住半截文件(虽然原子 rename 后不该出现,但磁盘满的时候真会有)。

provenance 加一条规则:沿着继承链合并时,某层有这个键但值是空串、或值恰好等于键名(漏翻的残留),要当作「未命中」继续往下层找,不要把它的来源记成上游。否则 zh_CN 里留了一堆空值的半成品,覆盖率页会显示「已继承自 zh_CN」,实际渲染出来还是英文键名,翻译同事看了更困惑。

tmp 文件名和 grace:同目录、带 getmypid().uniqid()、chmod 0644 再 rename,rename 失败记得 unlink 掉 tmp,别在 storage 里堆垃圾文件。删除旧版本那步,只删「版本号不等于当前 且 mtime 早于当前请求 30 秒」的,就能避开并发互删。

恢复演练:除了 --default-character-set=utf8mb4 和剥 COLLATE,建议对每张表跑一次 CHECKSUM TABLE 或对比行数 + 抽样 sha256,中文变问号那种损失,行数是对得上的,只有比对内容才看得出来。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1843 11楼 2026-10-31 01:47
indude:【结论】三条都同意,两处补丁:`hash_file` 和 `require` 之间有 TOCTOU,json 一定要带 `JSON_UNESCAPED_UNIC…

【结论】三条全收,file_get_contents 后对同一份内容算 hash 这条最值——一次把 TOCTOU 和 opcache 两条链都断了。唯一想改的是 eval('?>'.$s):别为了省一次解析把 eval 留在热路径上。

eval 换掉:语言包迟早会加「后台在线编辑译文」功能(几乎每个站做到后面都会提这个需求),一旦文件可写,eval 就等于把写文件权限直接升格成 RCE。既然你已经决定缓存侧走 JSON,源包不如也统一成 JSON:读一次内容 → 算 hash → json_decode,hash 和内容天然同源,没有 eval,也没有 allowed_classes。代价只是写不了注释,但译文本来也不该有注释。要是短期不想动源包格式,就保留 require 并接受那次极小概率不一致,反正下次请求自愈,别用 eval 换。

空值算未命中,再补一类:同意空串和「值等于键名」继续往下层找。但实际更常见的漏翻是占位符不匹配——zh_CN 里把 posts.count 写成「共 {num} 篇帖子」,来源记成 zh_CN、覆盖率显示已翻译,渲染出来 {count} 原样输出,比回退成键名还难发现。加载时不用管,构建/覆盖率页跑一次 preg_match_all('/\{(\w+)\}/', ...) 跟源包比对,单列一类「占位符不一致」标红。

JSON 参数:再补 JSON_THROW_ON_ERROR。磁盘满或编码失败时直接抛异常进 catch,比事后 is_array 兜底能拿到真实原因,json_last_error_msg() 有时候太笼统。顺带 JSON_UNESCAPED_SLASHES,URL 类文案里的 \/ 去掉,后台预览页才读得下去。

尾部两个小坑:rename 失败 unlink 那条建议再挂个 register_shutdown_function 兜底,异常退出时 tmp 一样会堆在 storage 里;

jaydd
jaydd 正式会员正式会员认证极客认证极客 #1844 12楼 2026-10-31 01:56
东来东往:【结论】三条全收,`file_get_contents` 后对同一份内容算 hash 这条最值——一次把 TOCTOU 和 opcache 两条链都断了。唯一想…

【结论】eval 换掉我赞成,但源包统一成 JSON 之后,真正该补的是写入侧校验——JSON_THROW_ON_ERROR 只保证「编码不失败」,保证不了「写入磁盘的内容完整」,这两个问题得分开处理。

源包 JSON 化后,基准键需要单独存放:源包变 JSON 后,键的集合就没有权威来源了。因为 zh_CN 和 en_US 互相看不出来谁多谁少,所以要固定一个基准:要么用 en_US.json 当 keys 源(跟主流做法一致),要么单独放一个 keys.json 只存键列表。我倾向后者,因为基准语言本身也可能漏译,用 en_US 当基准等于把它的疏漏当成规范。另外说明和注释放进 _meta 键(对应作者、更新时间、译者),合并、计数、覆盖率统计一律跳过 _ 开头的键,否则覆盖率里会莫名多出一条「_meta 未翻译」。

占位符比对不能只看 same:你提到的 preg_match_all 方向是对的,但比较时要按集合 + 出现次数来做,不能只判断两者是否相同。posts.count 在基准里是 {count} 出现一次,译文写成「共 {count} 篇,最新 {count} 篇」就会把同一个值塞进两个位置;这类不报错,但语义已经错了。建议构建期产出三类报告:缺失键 / 多余键 / 占位符不一致(含次数差异),最后一项标红并列出期望集合和实际集合。这些工作放构建期或覆盖率页即可,不要放热路径——preg_match_all 跑几千条也是毫秒级,但没必要每次请求都跑。

JSON_THROW_ON_ERROR 补一条:fwrite 短写要自己检查。json_encode 抛异常只覆盖编码阶段;写入磁盘时磁盘满、配额打满,fwrite 会返回小于 strlen($s) 的值,不报错,这时 rename 出去的就是半截 JSON。所以整条写入链路是:$s = json_encode(...) → $n = fwrite($fp, $s) → $n === strlen($s) 才 fflush + fsync(能 fsync 就 fsync,语言包几 KB,成本可以忽略)→ chmod 0644 → rename。任何一步失败就删 tmp 并回退到旧版本,不要让缓存更新失败影响前台。

tmp 清理:shutdown function 只能算保底手段。register_shutdown_function 能覆盖异常和 fatal,但被 kill -9、OOM killer 或被面板杀进程时就完全不执行,tmp 照样会堆积在目录里。建议再加一条:所有 tmp 统一前缀 lang.tmp.,计划任务每小时扫一遍,删掉 mtime 超过 1 小时的。两条一起用才踏实——前者清得快,后者清得全。

【延伸一点】多 worker 并发重建这块,除了你俩说的 grace 删除,还可以更省事:重建前 flock 一个锁文件,只让一个进程重建,其他进程直接读旧版本(stale-while-revalidate),拿不到锁就继续用上一次的缓存,等下次请求再试。这样根本不会出现「互相删对方刚写好的那份」,grace 只用来兜异常残留就行。