一套系统卖到多个地区,字段标题、表单名称、下拉选项的文字都要跟着语言换。这是做过国际化的人都处理过的题目,而 .NET 的标准答案是 .resx:字符串放进资源文件,编译成附属程序集,运行时以 CultureInfo.CurrentUICulture 解析。
bee-library 没有走这条路。语言资源是 Day 4 那十三种定义文件里的一种,跟 FormSchema、布局与菜单放在同一个根目录底下,以同一套机制加载、缓存、叠加。昨天谈的定制层里,最常被用到的就是这一份。
本篇说明:
- 译文为什么不编进程序集,而是放在定义文件里
- 一份语言资源长什么样,表单标题怎么对上它
- 翻译查不到的时候拿什么顶上,三条递补路线各有各的答案
- 两层资源在服务端挑还是客户端挑,缓存又切得多细
一、为什么本地化是一份定义文件
「这个字段在我们公司叫别的名字」是 ERP 反复会收到的需求。如果译文编译在程序集里,改一个字要走一次完整的构建与发布,份量跟修一段程序一样重。
这里不是在说 .resx 不好。它的模型是把译文当成程序集的一部分,适用范围很明确:跟着版本走、只在 .NET 进程内显示、一个部署只有一套。工具链成熟,访问有强类型与编译期保护。框架要的五件事刚好都落在这个范围之外:
| 框架需要的 | 为什么需要 |
|---|---|
| 译文能单独换掉,不重编、不换程序集 | 改一个标题不该等于发一次版 |
| 语言与定制化代码是每次调用的参数,不是进程的状态 | 同一个进程同时服务不同语言、不同定制的请求 |
| 一份定制只覆盖几个 key,其余沿用套装的 | 昨天那一节的 key 级叠加 |
| 前后端共用同一份资源文件 | 译文编进程序集,前端就得自己再维护一份,两份迟早会不一样 |
| 译的不只是字符串,还有带顺序的选项集 | 下拉选项要 code 与 text 成对,顺序也有意义 |
第一件是可以实测的。案例的服务器跑着,改掉 {DefinePath}/Language/zh-TW/Order.Language.xml 里某一个字段标题的值,不重编、不重启,下一次取那一份语言资源,拿到的就是新的那个字。缓存以文件本身当变更信号,这条路 Day 12 已经铺好了,语言资源只是走在上面的另一种定义。
第二件从类型上就看得出来。负责解析的服务对用户无状态,语言与定制化代码都是参数(节选自 ILanguageService.cs):
csharp
string GetLangText(string customizeId, string lang, string @namespace, string subKey);
.resx 那一侧对应的位置是 CultureInfo.CurrentUICulture,一个挂在线程上的环境值。单一用户的桌面应用里它很方便;一个同时服务许多 session 的服务器进程里,每次调用都要先把它设对再设回去,而且没有任何东西会提醒你漏了哪一次。
换掉的是存储,不是 API 表面
不走 .resx 不等于要放弃 .NET 的本地化接口。框架附了一个转接,把标准的 IStringLocalizer<T> 接到语言定义文件上(节选自 BeeStringLocalizer.cs):
csharp
public sealed class BeeStringLocalizer<T> : IStringLocalizer<T>
类型参数的简单名字就是 namespace,BeeStringLocalizer<Common> 读的是 Common 那一份资源。
换掉存储的代价有两笔:key 打错字编译器不会挡,.resx 那边会,这里要到运行时才看得见;GetAllStrings 回的是空集合,一份资源列不出来,因为底下那个服务只答得出单一个 key 的值。
二、一份语言资源的形状
文件路径是 {DefinePath}/Language/{lang}/{namespace}.Language.xml。一种语言配一个 namespace 就是一个文件。namespace 是自己切的分类,照表单、照模块、照功能都可以,怎么好管理就怎么分,跨表单共用的那一份习惯叫 Common。
内容分两区,下面节选自框架自带的 Employee.Language.xml:
xml
<LanguageResource Namespace="Employee" Lang="zh-TW">
<Items>
<LanguageItem Key="Schema.DisplayName" Value="員工" />
<LanguageItem Key="Table.Employee.DisplayName" Value="員工" />
<LanguageItem Key="Field.sys_id.Caption" Value="員工編號" />
<LanguageItem Key="Field.ref_dept_name.Caption" Value="部門名稱" />
</Items>
<Enums />
</LanguageResource>
Items 是文字,以 sub-key 为键;Enums 是选项集。一次查找的完整 key 是 {namespace}.{subKey},取第一个句点切开,所以 sub-key 本身可以带句点。
FormSchema 靠三个 sub-key 对上语言资源
把一份 FormSchema 本地化的是 FormSchemaLocalizer,namespace 取表单自己的 ProgId,然后照固定惯例组出 sub-key。整个对应只有三条规则加一个异常:
| 要换掉的 | sub-key | 由谁决定 |
|---|---|---|
| 表单名称 | Schema.DisplayName |
固定字符串 |
| 数据表名称 | Table.{TableName}.DisplayName |
FormSchema 里的数据表名 |
| 字段标题 | Field.{FieldName}.Caption |
FormSchema 里的字段名 |
| 下拉选项的文字 | 不走 sub-key,走 FormField.LangEnumName |
字段上指名的选项集名称 |
前三条的 key 全部从 FormSchema 推得出来,所以一份表单有哪些 key 要翻,不是靠人记,而是拿定义走一遍就列得出来。要产生翻译模板、要校验哪些 key 还没翻、要用工具批量补,都不必先读懂任何代码。定义是结构化数据,Day 5 列过它的三个直接后果:批量脚本扫得动、工具产得出来、AI 接得上。这三件事在语言 key 上特别成立,因为它的形状完全由另一份定义决定。
这里还有一个连带的分工,是 Day 7 留下来的。昨天那份定制布局片段上的英文标题也是同一件事:布局文件上可以写标题,但运行时会被已经本地化的 FormSchema 覆盖掉,覆盖不到的才留布局自己的。布局文件只描述结构,标题的权威来源是 FormSchema,而 FormSchema 的标题来自语言资源。三层下来只有一条路径会决定界面上那个字。
选项集是另一种东西
LanguageEnum 是一组有顺序、以 code 为键的项目:
xml
<Enums>
<LanguageEnum Name="Gender">
<Entry Code="M" Text="男" />
<Entry Code="F" Text="女" />
</LanguageEnum>
</Enums>
Code 是存进数据库的值,Text 是给人看的字,顺序照它们在 XML 里的先后保留,下拉菜单与表格的 lookup 绑定不必再排一次。字段要用哪一组,写在 FormSchema 的 LangEnumName 上:OrderStatus 这种裸名字在自己的 namespace 里找,Common.Gender 这种完整名称就跨到别的 namespace 去拿。共用的选项集因此只需要维护一份。
命中的时候,框架把整组 Entry 换成字段的 ListItems。字段原本可以在 FormSchema 里直接硬编码选项,写了 LangEnumName 就等于声明「这一列的选项改由语言资源供应」。
三、翻译查不到时的三条递补路线
缺翻译是常态,不是错误。一套系统上千张表单,永远会有还没翻到的地方,所以真正要设计的是「查不到的时候拿什么顶上」。
框架的答案不只一个。同一个语言子系统里有三个入口,三条路线的最后一段各不相同,其中一条还短一段:
| 入口 | 查找顺序 | 最后一段 |
|---|---|---|
纯文字 key(BO、IStringLocalizer) |
定制层与套装层各查一次当前语言,再各查一次默认语言 | 返回 {namespace}.{subKey} 这个 key 字符串本身 |
| 表单标题(前面那三个 sub-key) | 定制层与套装层各查一次当前语言 | 保留 FormSchema 里原本写的字面值 |
选项集(LangEnumName) |
定制层与套装层各查一次当前语言,再各查一次默认语言 | 保留字段原本的 ListItems |
纯文字 key 那一条摊开来是四次查找加一个结尾,选项集走的是同样四步、只有最后一段不同:
text
定制层(当前语言)─ 命中 → 返回
↓ 未命中
套装层(当前语言)─ 命中 → 返回
↓ 未命中 ← 当前语言 = 默认语言时,以下两步整段跳过
定制层(默认语言)─ 命中 → 返回
↓ 未命中
套装层(默认语言)─ 命中 → 返回
↓ 未命中
返回 "{namespace}.{subKey}"
前两步与后两步是同一段程序跑两次,差别只在语言参数。定制层与套装层之间怎么挑,用的是昨天那个纯决策组件,语言文字的粒度是 key 级,选项集是整份取代。
为什么表单标题那一条短一段
三条路线的差别不是疏漏,判准是这个位置手上还有没有一个现成的、人看得懂的值。
FormSchema 的每一个字段本来就带着 Caption,那是写定义的人自己打的字。缺翻译的时候直接留着它,界面上出现的是一个真的能读的标题,没有必要再退到另一种语言去撞运气。选项集同理,字段可能本来就有硬编码的 ListItems。
纯文字 key 什么都没有。Common.OK 这种 key 底下没有任何备用文字,所以它多退一段到默认语言;连默认语言都查不到的时候,框架选择把 key 原样显示在界面上。这是刻意的:一个看得见的 Common.OK 会被回报,一个空字符串或一个沉默的默认值不会。
实测的条件是当前语言 en-US、默认语言 zh-TW、只有 zh-TW 有译文。同一份数据下,纯文字 key 拿到 zh-TW 的译文,表单标题与数据表名称却维持 FormSchema 里的英文原字,没有变成中文。同一个子系统里两种行为并存,而两种都是对的。
起点的那个语言从哪里来
当前语言就是 Day 13 那个 session 属性,登录时从用户数据表读,读不到退到部署层的默认设置。
默认语言是另一份设置,回答的问题也不同:一个是「这个 session 说哪种语言」,另一个是「这个 key 在它的语言里查不到时往哪里退」。两者分开,连默认值都不一样,前者是 zh-TW,后者是 en-US。所以同一个部署里,用户讲中文、资源退回英文是合理的,因为套装的资源本来就可能只有英文最完整。
四、叠加在哪一端,缓存切到多细
昨天已经建立过前提:服务端不先叠,两层原始定义都送出去,客户端拿到之后用同一个决策组件挑。语言资源走同一条路,System.GetLanguage 给套装层,System.GetCustomizeLanguage 给定制层,而定制是哪一份,由服务端看 session 决定。
客户端因此要先知道这张表单会用到哪几个 namespace。这件事 FormSchema 自己答得出来:表单的 ProgId 算一个,字段上以完整名称指名的 LangEnumName 各算一个。接着把这些 namespace 的两层一次抓齐,会退到默认语言的话那一种语言也一起抓,收成一份同步的快照,本地化那一步就不必再跑服务端(节选自 FormDefinitionLoader.cs)。
csharp
var customize = await _defineAccess.GetCustomizeLanguageAsync(language, ns);
var @base = await SafeGetLanguageAsync(language, ns);
snapshot[BuildKey(language, ns)] = new LanguageLayers(@base, customize);
代价是往返变多:组一张表单的定义要抓语言两层、布局两层。所以在 Avalonia 那一头这件事是选配的,表单没有指定加载器就不本地化、也不套定制,哪几张表单要付这个成本由应用自己决定。
缓存一格就是一种语言配一个 namespace,改动其中一格,其他语言与其他 namespace 都不受影响。这让第二节那句「怎么好管理就怎么分」多一件事要想:一个语言文件就是一个缓存单位。全部塞成一份,改一个字就整份重读;切得太碎,客户端组一张表单要多跑好几趟。
这里缺一个文件是正常答案,不是故障:读取路径不丢异常,「查过没有」这件事本身也缓存得起来。FormSchema 相反,少一个文件就是设置错了。Day 4 分过的那两类,语言资源落在宽松的那一边。
两端的语言也不是同一个来源:服务端读 session 上那个语言,桌面端默认读进程的 UI 文化,要覆盖也可以。服务端决定的是这一次调用回什么消息,前端决定的是用户现在看到的界面用哪种语言,本来就是两件事。
回到 Northwind
案例的公司数据填了定制化代码 northwind-demo,套装与定制两层各有一份订单的语言文件。套装那一份把订单上的标题全部声明了一遍,其中 Field.customer_rowid.Caption 是「客户」、Field.ref_customer_name.Caption 是「客户名称」。定制那一份整份就这样(Order.Language.xml):
xml
<LanguageResource Namespace="Order" Lang="zh-TW">
<Items>
<LanguageItem Key="Field.customer_rowid.Caption" Value="經銷商" />
<LanguageItem Key="Field.ref_customer_name.Caption" Value="經銷商名稱" />
</Items>
<Enums />
</LanguageResource>
这家公司把「客户」叫成「经销商」,于是覆盖那两个 key,其余一个字都没写。两个 key 里,清单界面上出现的是后面那一个:
实测两个 API,套装层回二十四个 key、定制层回两个,两份都原样送到客户端,挑哪一个是查找当下才发生的事。
案例的另一半在 en-US。那个语言连文件夹都没有,两层都回空字符串,而界面照样是好的:三个 sub-key 一个都没命中,标题就留在 FormSchema 自己写的英文,走的正是第三节那条短一段的路线。订单的状态列则直接在定义里列了三个选项、没有指名任何选项集,选项集那一条一次都没有被走到。
这正是这套机制刻意保留的默认:没有翻译不是一种待修复的状态,是一种合法的状态。一个只卖一个地区、标题就用开发时打的那个字的部署,不必因为框架支持多语言就先产出一堆语言文件。等到真的要多一种语言,做的事情是往那个文件夹放一个文件,FormSchema、布局与代码都不必动。
小结
本地化落在定义文件,译文从此不必跟着代码一起交付。改一个标题就是改一份数据,不必重编、不必换程序集、不必重启;代价是编译器不再替你检查 key,错字要到运行时才看得见。
- 有五件事
.resx的模型接不住:译文要能单独换掉、语言与定制化代码是每次调用的参数、一份定制只覆盖几个 key、前后端共用同一份资源文件、译的不只是字符串;IStringLocalizer这个标准接口留着,换掉的只是底下的存储 - 一种语言配一个 namespace 一个文件,
FormSchema靠三个 sub-key 对上它,而那三个都是从定义本身算出来的;选项集不走 sub-key,走字段上写的那个名字,可以跨 namespace 共用 - 递补路线有三条,最长的四次查找加一个结尾,最短的两次就停
最该带走的是三条路线为什么不一样。长度不是由重要性决定,而是看这个位置手上还有没有一个人看得懂的值:表单标题有现成的字面值可以留,所以不必再退;纯文字 key 什么都没有,才多退一段到默认语言。
全部都查不到的那一段也是一个决定。框架回的是 key 字符串本身,不是空字符串、也不是某个看起来没问题的默认值。缺翻译没有一个正确答案,只有要不要被看见,而看得见的那一种才会有人回报。
明天换一种情况:一家公司要的差异大到语言与布局都接不住的时候,业务 plugin 挂在哪里,而定制化这条线目前又走到了哪里。
本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计