Sparkdo.Localization
3.0.0-preview.1
dotnet add package Sparkdo.Localization --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.Localization -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.Localization" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.Localization" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.Localization" />
paket add Sparkdo.Localization --version 3.0.0-preview.1
#r "nuget: Sparkdo.Localization, 3.0.0-preview.1"
#:package Sparkdo.Localization@3.0.0-preview.1
#addin nuget:?package=Sparkdo.Localization&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.Localization&version=3.0.0-preview.1&prerelease
Sparkdo.Localization
字典式本地化的运行时包:资源(Resource)→ 贡献者(Contributor)→ 字典(Dictionary)三级模型,
按「精确文化 → 直接父文化 → 资源默认文化 → 基础资源链」回退取值;JSON 字典文件经 Sparkdo.Json
产品域源生成 context 直读 UTF-8(无反射 fallback、AOT 友好),文件来源是标准 IFileProvider。
AddSparkdoLocalization() 一行接线并替换标准 IStringLocalizerFactory,裸 ServiceCollection 零前置注册。
数据流:资源注册(Options)→ 首次 Create 懒初始化贡献者 → FrozenDictionary 文化字典 → 标准 IStringLocalizer 消费。
目录
- 快速开始
- 能做什么、不做什么
- 安装
- 寻址入口矩阵
- 回退链语义
- 全量枚举与支持文化
- DI 接线清单与替换面
- SparkdoLocalizationOptions 逐成员
- JSON 字典文件格式
- 文件贡献者与 IFileProvider
- 枚举本地化
- 主要类型逐成员语义
- 扩展点与实现契约
- 典型用法配方
- 调用期拒绝清单与异常一览
- 线程安全、缓存与生效时机
- 性能特征
- AOT 立场
- 常见坑
- 与其他包的关系
快速开始
资源类 + JSON 文件 + 一行接线
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Sparkdo.Localization;
public sealed class MyResource; // 资源标记类,不需要任何成员
services.AddSparkdoLocalization(options =>
{
options.DefaultResourceType = typeof(MyResource);
options.Resources.Add<MyResource>("en") // "en" = 兜底文化
.AddJsonFiles("Localization/MyResource"); // 相对 IFileProvider 根的目录
});
public sealed class MyService(IStringLocalizer<MyResource> localizer)
{
public string Greet(string name) => localizer["Order.Greet", name];
}
Localization/MyResource/zh-Hans.json:
{ "culture": "zh-Hans", "texts": { "Order.Greet": "{0},你好" } }
三个动作缺一不可:注册资源、挂贡献者、声明兜底文化。DefaultResourceType 指向的类型也必须 Add,
否则默认资源寻址直接抛出(fail-closed;见入口矩阵)。
不要文件:自己写贡献者
本域内置的只有 JSON 文件贡献者;内存/数据库来源请实现 ILocalizationResourceContributor
(约 20 行,见配方 3)。贡献者的注册顺序就是优先级:后注册者优先。
不走容器
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Localization;
using Sparkdo.Localization;
using Sparkdo.Localization.VirtualFiles;
var resource = new NonTypedLocalizationResource("MyApp", "en");
var contributor = new JsonFileLocalizationResourceContributor(
"Localization/MyApp", new PhysicalFileProvider(AppContext.BaseDirectory));
// 显式传了文件提供器时,这里的 ServiceProvider 只是占位以满足 Initialize 契约
// (只有不传文件提供器时,Initialize 才会从它解析 IFileProvider)
using var services = new ServiceCollection().BuildServiceProvider();
contributor.Initialize(new LocalizationResourceInitializationContext(resource, services));
IStringLocalizer localizer = new SparkdoDictionaryBasedStringLocalizer(
resource, baseLocalizers: [], localizationOptions: new SparkdoLocalizationOptions());
这条路没有 IStringLocalizer<T> 注入、没有工厂缓存、没有全局贡献者,且必须自己调 Initialize。
能做什么、不做什么
| 做 | 不做 |
|---|---|
| 字典式资源 + 多来源贡献者合并与覆盖 | 解析 .resx/.resources——resx 只是未注册类型的兜底生态 |
| 文化回退链:精确 → 直接父文化 → 资源默认文化 → 基础资源链 | 完整父文化链上溯(zh-Hans-CN 只试 zh-Hans,不会自动试 zh) |
接管标准 IStringLocalizerFactory、IStringLocalizer<T> 直接注入 |
HTTP 内容协商、请求文化中间件(属 ASP.NET Core) |
| JSON 文件热更新(变更令牌失效重建) | 语言变更事件、进程级语言广播 |
| 枚举值本地化(三阶键序、泛型非装箱) | [Description] / Display 特性解析 |
ILocalizableString 对象模型与 L:/F: 持久化协议 |
协议加密、版本协商、把序列化串当查询语言 |
语言列表、语言映射表、"en-US;en" 设置值解析 |
语言协商、按用户存储语言的持久层(ILanguageProvider 是缝) |
| 外部资源发现缝(多租户/数据库托管资源) | 外部存储的实现与租户上下文 |
| 命中路径零分配、AOT/裁剪友好 | 编译期把 JSON 烘焙成源生成字典(留待后续批次) |
安装
dotnet add package Sparkdo.Localization
目标框架 net10.0;net11.0;契约层见 Sparkdo.Localization.Abstractions(本包已传递引用)。
寻址入口矩阵
同一个资源对象,从不同入口进去能看到的内容不一样——这是本包最高频的误用来源:
| 入口 | 按类型注册 Add<T>() |
按名注册 Add("name") |
外部存储 | 找不到时 |
|---|---|---|---|---|
factory.Create(resourceType) |
✅ | ❌ | ❌ | 落标准 resx 工厂 |
factory.Create(baseName, location) |
❌ | ❌ | ❌ | 永远走 resx,本包不参与 |
IStringLocalizer<T> 注入 |
✅(内部即 Create(typeof(T))) |
❌ | ❌ | 同上 |
CreateByResourceNameOrNull(name) |
✅ | ✅ | ✅(本地未命中才问外部) | 返回 null |
CreateByResourceName(name) 扩展 |
✅ | ✅ | ✅ | 抛 InvalidOperationException |
CreateDefaultOrNull() |
✅(当 DefaultResourceType 已注册) |
看类型是否注册 | ❌ | 未配置 → null;配置未注册 → 抛 InvalidOperationException(fail-closed;AllowUnregisteredDefaultResource = true 才回退 resx) |
LocalizableString.Create<T>(name) |
✅ | ❌ | ❌ | 未命中再查默认资源一次,仍无则未命中标记 |
LocalizableString.Create(name, "res") |
✅ | ✅ | ✅ | 同上 |
四条推论:按名注册的资源类型入口看不见;DefaultResourceType 的类型必须也注册;
Create(baseName, location) 是 resx 专用通道;资源名按 Ordinal 区分大小写。
ISparkdoStringLocalizerFactory 与 IStringLocalizerFactory 是两条描述符、同一个单例实例;
CreateByResourceName* 等扩展声明在 IStringLocalizerFactory 上,所以注入标准接口再调扩展才是常规写法。
回退链语义
单键取值(localizer[key])顺序:
| 序 | 档 | 触发条件 | 关掉它 |
|---|---|---|---|
| 1 | 精确文化(CultureInfo.CurrentUICulture.Name) |
总是 | — |
| 2 | 直接父文化 | TryToGetFromBaseCulture(默认 true)且文化名含 - |
置 false |
| 3 | 资源默认文化 | TryToGetFromDefaultCulture(默认 true)且 DefaultCultureName 非空 |
置 false 或资源不配 |
| 4 | 基础资源链 | BaseResourceNames(InheritResourceAttribute / AddBaseTypes / AddBaseResources) |
不声明继承 |
| 5 | 未命中标记 | new LocalizedString(key, key, resourceNotFound: true),不抛异常 |
— |
- 第 2 档只走一级,
CultureInfo.Parent语义而非字符串切片:zh-Hans-CN → zh-Hans(不是zh)、tr-TR → tr。 父文化名按文化名缓存(128 条上限),稳态零分配。 - 每档内部都是贡献者倒序扫描:同资源内后注册的先问,先命中即返回。全局贡献者在首次解析时追加到列表末尾, 因而优先级最高(覆盖资源自身贡献者)。
- 第 4 档是「另一个本地化器再走一遍它自己的 1→3」,按
BaseResourceNames声明顺序; 只有目标文化与当前CurrentUICulture不同时才CultureHelper.Use换装。 - 基础资源名拼错不会有任何提示,那一级直接不存在(可选继承不该拖垮启动的代价)。
- 命中返回字典内预构建的
LocalizedString,SearchedLocation恒为null;未命中标记的Value就是键名。 - 带参索引器先查值再
string.Format;零参数时跳过格式化(字面花括号不会炸)。 格式化不带 provider:查值按CurrentUICulture,占位符按CurrentCulture。
全量枚举与支持文化
GetAllStrings 的分层覆盖顺序(逐层写进同一个 Ordinal 字典,后写覆盖):
① includeBaseLocalizers 的基础本地化器(递归)
② includeParentCultures 时的资源默认文化
③ includeParentCultures 且文化名含 '-' 时的直接父文化
④ 精确文化(总是参与)
- 父文化两档由
includeParentCultures控制,不看TryToGetFromBaseCulture/TryToGetFromDefaultCulture。 includeDynamicContributors: false只在全量枚举里生效;单键取值永远会问动态贡献者。- 基础本地化器是 resx 时,
MissingManifestResourceException在这里被当作「无内容」吞掉。 - 返回值是本次调用内构建的覆盖字典的值视图(零拷贝),可枚举但别强转写回。
GetSupportedCulturesAsync() 走贡献者层合并,多贡献者对同一文化的重复声明按 Ordinal 集合语义去重
(保留首次出现顺序);JSON 里声明了 culture 但没有 texts 的文化仍会出现(支持但零键)。
非 Sparkdo 本地化器经扩展方法降级:三参枚举退化为标准 GetAllStrings(includeParentCultures),
GetSupportedCulturesAsync() 返回空集。
DI 接线清单与替换面
| 注册 | 生命周期 | 语义 | 怎么换掉 |
|---|---|---|---|
AddOptions<SparkdoLocalizationOptions>()(+ Configure(configure)) |
— | 配置委托按调用顺序叠加;configure 为 null 时不追加 |
继续 services.Configure<T>(...),写在接线前后都生效(早于首次解析即可) |
ResourceManagerStringLocalizerFactory |
Singleton | TryAdd;ILoggerFactory 可缺省(缺省 NullLoggerFactory) |
先自行注册 |
SparkdoStringLocalizerFactory |
Singleton | TryAdd(具体类型) |
— |
IStringLocalizerFactory |
Singleton | Replace——本产品就要接管标准工厂解析入口 |
接线之后再 AddSingleton<IStringLocalizerFactory>(...) |
ISparkdoStringLocalizerFactory |
Singleton | TryAdd,转发到同一实例 |
先自行注册 |
IStringLocalizer<> → StringLocalizer<> |
Transient | TryAdd;于是无需调 AddLocalization() |
先自行注册(AddLocalization() 同为 TryAdd,谁先谁赢) |
IExternalLocalizationStore |
Singleton | TryAdd → NullExternalLocalizationStore |
自行注册 |
ISparkdoEnumLocalizer |
Singleton | TryAdd → SparkdoEnumLocalizer |
自行注册 |
ILanguageProvider |
Singleton | TryAdd → DefaultLanguageProvider |
自行注册 |
ILocalizableStringSerializer |
Singleton | TryAdd → LocalizableStringSerializer |
自行注册 |
- 重复调用幂等:
IStringLocalizerFactory描述符数恒为 1。 ITemplateLocalizer与SparkdoExceptionLocalizationOptions不在清单内——本包只提供形状,不注册不实现。- 裸
ServiceCollection零前置:不需要AddLocalization()、ILoggerFactory、IFileProvider。
SparkdoLocalizationOptions 逐成员
| 成员 | 语义 |
|---|---|
LocalizationResourceDictionary Resources { get; } |
资源注册表,双索引(资源名 / 资源类型)。仅配置期可用:IOptions<T> 首次解析即固定,之后改动不影响已解析出的本地化器。非线程安全 |
Type? DefaultResourceType { get; set; } |
默认资源类型。决定 CreateDefaultOrNull() 与 LocalizableString 的兜底目标;该类型必须也 Add 进 Resources,否则默认资源寻址抛 InvalidOperationException(fail-closed;AllowUnregisteredDefaultResource = true 才回退 resx) |
List<Func<ILocalizationResourceContributor>> GlobalContributors { get; } |
全局贡献者委托工厂,对所有资源(含外部存储提供的资源)生效;首次解析时追加到该资源贡献者列表末尾→ 优先级最高,且每资源只执行一次 |
SparkdoLocalizationOptions AddGlobalContributor<TContributor>() |
TContributor : new() 的糖,无反射、AOT 安全 |
List<LanguageInfo> Languages { get; } |
语言列表;DefaultLanguageProvider 直接返回同一实例(不是快照) |
Dictionary<string, List<NameValue>> LanguagesMap { get; } |
包名 → 应用语言 → 包内语言名的映射,经 SparkdoLocalizationOptionsExtensions 读写 |
Dictionary<string, List<NameValue>> LanguageFilesMap { get; } |
包名 → 应用语言 → 语言文件名的映射,同一套扩展 |
bool TryToGetFromBaseCulture { get; set; }(默认 true) |
单键取值的父文化档(只上溯一级,且文化名须含 -)。每次现读,运行期改了立刻生效;不影响 GetAllStrings 分层 |
bool TryToGetFromDefaultCulture { get; set; }(默认 true) |
单键取值的资源默认文化档。同样是每次现读;不影响 GetAllStrings 分层 |
JSON 字典文件格式
{
// 注释被接受
"culture": "zh-Hans",
"texts": {
"Hello": "你好",
"Menu": { "Settings": "设置", "Deep": { "Key": "深层值" } }, // → Menu__Settings / Menu__Deep__Key
"List": [ "第一项", "第二项" ], // → List__0 / List__1
"Empty": null, // → ""
"Yes": true, // → "True"
"No": false, // → "False"
"Price": 1.50, // → "1.50"(原样文本,保留书写形式)
"Big": 1e3, // → "1e3"
"Multi": "a\r\nb\rc" // → "a\nb\nc"(行尾统一为 \n)
}
}
| 规则 | 实测行为 |
|---|---|
| 属性名 | 大小写不敏感("CULTURE"、"Texts" 都认);未识别的顶层属性被忽略 |
culture 缺失或 "" |
整个文件跳过(Build* 返回 null),不报错 |
texts 缺失或 null |
不跳过:该文化被声明支持但零个键 |
| 嵌套对象/数组 | 键以 __ 连接,数组下标十进制,任意深度(m__0__1) |
| 键比较 | Ordinal、区分大小写(NUM 查不到 num) |
| 空键 | 键非空校验只在写入叶子条目时发生:顶层空键 + 标量值抛 JsonException("本地化 JSON 的键不能为空。");顶层空键的值是对象/数组时其子键直接以自身名落入字典({"":{"a":"x"}} → 键 a);嵌套空键合成 a__ |
| 行尾 | 含 \r 才替换为 \n;不含时复用原串(零分配) |
| 注释 / 尾逗号 / BOM | 都允许(BOM 自动跳过 3 字节) |
| 结构非法 | JsonException(texts 为数组/字符串、顶层为数组、空输入、语法错误);从文件读时包成 InvalidOperationException("非法的本地化文件格式:{路径|文件名}") |
| 平铺键与嵌套同键 | 谁在 JSON 里靠后谁赢——别这么写 |
解析走 Sparkdo.Json 源生成 context(LocalizationJsonContext,[SparkdoJsonContext] 参与应用级 resolver 聚合),
texts 值先保持 JsonElement 再在构建期展平,成本全在构建路径。
文件贡献者与 IFileProvider
JsonFileLocalizationResourceContributor(directoryPath, fileProvider = null):
- 目录路径相对文件提供器根;构造期归一(
\→/、剥首尾/)为「无前导斜杠」形态。 于是Loc、/Loc、Loc/、\Loc对 Physical / Manifest / Composite / VFS 各提供器一致可见 (前导斜杠形态对PhysicalFileProvider不可见,这是移植时修掉的一处行为差异)。 归一只处理反斜杠与首尾斜杠:./Loc这类点段前缀不会被剥掉,原样交给提供器,能否可用取决于提供器自己的规范化。 归一后为空(" "、"/"、"\\")抛ArgumentException(参数名directoryPath),null抛ArgumentNullException。 - 文件来源优先级:显式传入的
IFileProvider> DI 注册的IFileProvider>PhysicalFileProvider(AppContext.BaseDirectory)。 Sparkdo.VirtualFileSystem 注册的IFileProvider直接可用。 - 只扫一层,只认扩展名
.json(大小写不敏感);子目录与其他文件忽略。 - 同文化多文件按文件名 Ordinal 升序合并,靠后者覆盖同键——排序是显式做的,不依赖提供器枚举序 (物理目录在 Linux 上是哈希序,靠枚举序会让覆盖关系漂移)。
- 懒构建 + 双检锁 +
volatile换出:首次访问才读目录并冻结,之后读路径无锁零分配。 - 热更新:首次构建注册一次
ChangeToken.OnChange(provider.Watch($"{dir}/*.*")),触发即作废、下次访问重建。 目录直接子级的任何增删改都会触发,不只是.json。 - 目录不存在 → 空集合,不抛。
- 必须先
Initialize(走工厂/AddJsonFiles时自动):否则取值抛InvalidOperationException。 - 坏文件在首次取值时才炸:异常从
localizer[key]冒出。启动期主动跑一遍配方 8。
AddJsonFiles(directoryPath) 与 AddJsonFiles(directoryPath, fileProvider) 是两个挂贡献者的糖,
后者不经 DI 解析(嵌入资源、组合根、测试替身优先用它),两个重载都返回资源对象本身以便继续链式配置。
枚举本地化
ISparkdoEnumLocalizer(已注册为单例),三阶键序、首个命中即返回:
Enum:{Type}.{Member} → {Type}.{Member} → {Member} → 字面量兜底
public enum OrderStatus { Pending = 1, Shipped = 2 }
enumLocalizer.GetString(OrderStatus.Pending); // 默认资源;不装箱
enumLocalizer.GetString(OrderStatus.Pending, moduleLocalizer); // 指定本地化器(元素可为 null,跳过)
enumLocalizer.GetString(typeof(OrderStatus), (object)status); // 非泛型形态(值会被装箱)
- 第三阶只对已定义成员查表。未定义值与
[Flags]组合直接回退其文本形式((OrderStatus)7 → "7"、Read|Write → "Read, Write"),永不返回null。 - 整条链最多两个键串分配(裸成员名复用已到手的名串)。
GetString(Type, object)对非枚举类型抛ArgumentException;泛型重载由where TEnum : struct, Enum编译期挡住。- 默认资源不可得时最终只会拿到成员名本身——看起来"没本地化",但不抛。
主要类型逐成员语义
AddSparkdoLocalization(this IServiceCollection, Action<SparkdoLocalizationOptions>? configure = null)
services 为 null 抛 ArgumentNullException;configure 允许为 null(等价于只接线不配置)。
返回同一个 services 引用。见上面的注册清单。
SparkdoStringLocalizerFactory
| 成员 | 语义 |
|---|---|
IStringLocalizer Create(Type resourceType) |
注册过的类型 → 字典本地化器(懒初始化 + 并发缓存,同类型反复解析返回同一实例);未注册类型 → 转交 resx 工厂 |
IStringLocalizer Create(string baseName, string location) |
恒转 resx 工厂,本包不解释 baseName/location |
IStringLocalizer? CreateByResourceNameOrNull(string resourceName) |
本地注册表(按名,Ordinal)→ 外部存储 → 都没有则 null;resourceName 空白抛 ArgumentException |
Task<IStringLocalizer?> CreateByResourceNameOrNullAsync(string resourceName) |
同上,异步只发生在外部存储的资源发现上;本地字典资源是同步完成的快速路径 |
IStringLocalizer? CreateDefaultOrNull() |
DefaultResourceType 未配置 → null;已注册 → 等价 Create(该类型);配置了但未注册 → 抛 InvalidOperationException(fail-closed,AllowUnregisteredDefaultResource = true 才回退 resx) |
| 构造参数 | 四个入参都非空校验;IOptions<T> 在构造时取 .Value 并长期持有 |
懒初始化做的事,按序:追加全局贡献者 → 逐个 contributor.Initialize(context) → 按 BaseResourceNames
递归创建基础本地化器(解析不到的名字跳过)→ 产出 SparkdoDictionaryBasedStringLocalizer。
环检测只针对当前执行流的重入(AsyncLocal 初始化栈),重入即抛带完整环路径的 InvalidOperationException;
跨线程的并发首次解析由工厂缓存的 Lazy(ExecutionAndPublication) 串行化,不会误判成环。
SparkdoDictionaryBasedStringLocalizer(公开、可继承,成员多为 protected virtual)
| 成员 | 语义 |
|---|---|
this[string name] |
回退链取值 |
this[string name, params object[] arguments] |
取值后 string.Format(按 CurrentCulture);零参数跳过格式化 |
GetAllStrings(bool includeParentCultures) |
标准接口形态,含父文化与基础资源链 |
GetAllStrings(bool, bool includeBaseLocalizers, bool includeDynamicContributors) |
细粒度三开关(本包 ISparkdoStringLocalizer 扩展) |
GetAllStringsAsync(...) |
异步贡献者填充(FillAsync) |
GetSupportedCulturesAsync() |
该资源的贡献者文化并集(去重) |
Resource / BaseLocalizers / LocalizationOptions |
只读诊断面 |
GetLocalizedStringOrNull(name, cultureName, tryDefaults) |
1→3 档;覆写它以插入自定义档 |
LocalizationResourceBase / LocalizationResource / NonTypedLocalizationResource
| 成员 | 语义 |
|---|---|
string ResourceName { get; } |
稳定资源名:类型化资源取 [LocalizationResourceName],未标注则类型全名(按类型缓存) |
string? DefaultCultureName { get; set; } |
兜底文化;空或 null 即关闭第 3 档 |
List<string> BaseResourceNames { get; } |
继承链,声明顺序即查找顺序;AddBaseResources / AddBaseTypes 追加并按 Ordinal 去重 |
LocalizationResourceContributorList Contributors { get; } |
贡献者列表;Add 的顺序即优先级升序 |
LocalizationResource(Type, string?, ILocalizationResourceContributor?) |
构造期读取类型上的 IInheritedResourceTypesProvider 特性入链 |
NonTypedLocalizationResource(string name, ...) |
只有名字索引,类型入口看不见它 |
LocalizationResourceContributorList 是 List<ILocalizationResourceContributor> 的派生:
GetOrNull 逆序索引扫描(零分配)、Fill / FillAsync 正序覆盖写入、
GetSupportedCulturesAsync 单贡献者透传 / 多贡献者去重合并。
JsonLocalizationDictionaryBuilder(静态)
| 成员 | 语义 |
|---|---|
ILocalizationDictionary? BuildFromUtf8Bytes(ReadOnlySpan<byte>) |
BOM 自动跳过;无 culture 或 culture 为空 → null;结构非法 → JsonException;键空(顶层)→ JsonException |
ILocalizationDictionary? BuildFromFile(string) |
同上,任何读失败或 JsonException 都包成 InvalidOperationException("非法的本地化文件格式:{路径}") |
StaticLocalizationDictionary
FrozenDictionary<string, LocalizedString> + Ordinal;值在构造期即预构建,读路径零分配。
CultureName 原样保留(不做大小写归一)。Fill 逐项写入目标字典(同键覆盖)。
ILocalizableStringSerializer / LocalizableStringSerializer
L:{resource},{name} / F:{value} 文本协议;Serialize(null) → null;
不认识的 ILocalizableString 实现抛 InvalidOperationException;反序列化用 Span 切片,不合规输入按固定文本兜底。
L:/F: 是文本协议而非查询语言:不做转义、不做版本协商;边界见配方 6 的表。
CultureHelper(静态)
| 成员 | 语义 |
|---|---|
IDisposable Use(string culture, string? uiCulture = null) / Use(CultureInfo, CultureInfo?) |
同时写 CurrentCulture 与 CurrentUICulture,Dispose 还原;AsyncLocal 语义(对同逻辑上下文下游含 await 之后可见,对其他线程不可见) |
bool IsRtl |
当前 UI 文化是否从右向左 |
bool IsValidCultureCode(string) |
以 CultureInfo.GetCultureInfo 判合法性;空白与非法名返回 false,不抛 |
string GetBaseCultureName(string) |
直接父文化名(zh-Hans-CN → zh-Hans、en → ""),按文化名缓存(上限 128);非法文化名抛 CultureNotFoundException |
bool IsCompatibleCulture(source, target) |
方向敏感的基础↔具体判定,含 zh 系父子链特判 |
LanguageInfo / ILanguageProvider / LanguageInfoExtensions / SparkdoLocalizationOptionsExtensions
LanguageInfo(cultureName, uiCultureName?, displayName?):省略项回落为 cultureName,
派生 TwoLetterISOLanguageName;文化名非法即抛 CultureNotFoundException,空白抛 ArgumentException。
ChangeCulture(...) 同样不保留旧值。
ILanguageProvider.GetLanguagesAsync() 默认实现返回 options.Languages 的同一实例(零分配、非快照)。
FindByCulture(cultureName, uiCultureName?):双向精确 → 仅 CultureName → 仅 UiCultureName,全不中 null。
AddLanguagesMapOrUpdate / GetLanguagesMap / GetCurrentUICultureLanguagesMap(LanguageFilesMap 同构):
按 Name 就地更新既有 NameValue,查询未命中原样返回入参语言。
LocalizationSettingHelper / LocalizationSettingNames
ParseLanguageSetting("en-US;en") → (CultureName, UiCultureName);单段合法则两段同值;
任一段非法、段数不为 2、或段内带空格(不修剪)都整体回退默认文化("en");入参空白抛 ArgumentException。
LocalizationSettingNames.DefaultLanguage 值是移植保留的 "Abp.Localization.DefaultLanguage",
本包只做常量与纯函数,不绑定任何设置存储。
预留缝
ITemplateLocalizer:只有接口,无实现、无注册,留给模板处理产品。ExceptionHandling.SparkdoExceptionLocalizationOptions:错误码命名空间前缀 → 资源类型的映射表 (MapCodeNamespace返回自身可链式;键按 Ordinal),供异常转 HTTP 一类消费者使用,本包不消费。NullExternalLocalizationStore:未注册外部存储时的默认实现,四个查询全部快速返回空。
扩展点与实现契约
| 想做的事 | 接哪个 | 必须遵守 |
|---|---|---|
| 换内容来源(数据库、租户、配置中心) | ILocalizationResourceContributor |
GetOrNull 是热路径:命中返回可长期持有的实例(每次 new LocalizedString 就把零分配还回去了);未命中返回 null 而非空串;内容会变就 IsDynamic => true;依赖在 Initialize 里从 context.ServiceProvider 解析,别在构造期解析 |
| 自定义单文化字典 | ILocalizationDictionary |
键比较必须 Ordinal;Fill 是"写入目标字典、同键覆盖",不要清空目标 |
| 接管解析链 | 继承 SparkdoDictionaryBasedStringLocalizer 覆写 GetLocalizedStringOrNull / GetLocalizedString / GetAllStrings |
缓存需自己做(工厂缓存的是基类实例) |
| 换工厂行为 | 继承 SparkdoStringLocalizerFactory(Create* 皆 virtual,InnerFactory/ServiceProvider/ExternalLocalizationStore 为 protected) |
接线后再 AddSingleton<IStringLocalizerFactory>(自己的);别绕过 CreateInternal 的缓存与环守卫 |
| 多租户 / 数据库托管资源 | IExternalLocalizationStore |
只在按名寻址路径被问,本地注册表命中即用;返回可长期持有的资源实例(全局贡献者会被追加进 Contributors,每次返回新副本会不断累积) |
| 给所有资源统一追加内容 | options.GlobalContributors.Add(factory) 或 AddGlobalContributor<T>() |
首次解析时追加到末尾 → 优先级最高;对同一资源只追加一次 |
| 动态语言列表 | ILanguageProvider |
单例;默认实现透传 options.Languages |
典型用法配方
1. 一个资源的多种来源(覆盖顺序可控)
var resource = options.Resources.Add<MyResource>("en");
resource.AddJsonFiles("Localization/MyResource"); // ① 包内置(先注册 = 优先级低)
resource.Contributors.Add(new TenantContributor(...)); // ② 租户覆盖(后注册 = 优先级高)
单键取值与全量枚举都是后注册者赢,不会出现"查得到但枚举里没有"。
2. 默认资源 + 领域资源双层
options.DefaultResourceType = typeof(DefaultResource);
options.Resources.Add<DefaultResource>("en").AddJsonFiles("Localization/Default");
options.Resources.Add<IdentityResource>("en")
.AddJsonFiles("Localization/Identity")
.AddBaseResources("Default");
AddBaseResources 声明链 + LocalizableString 的默认资源兜底是两套独立机制,会同时生效。
3. 内存字典贡献者(最小实现)
using System.Collections.Frozen;
using Microsoft.Extensions.Localization;
using Sparkdo.Localization;
public sealed class DictionaryContributor(
string cultureName,
IReadOnlyDictionary<string, string> entries) : ILocalizationResourceContributor
{
private readonly FrozenDictionary<string, LocalizedString> _entries =
entries.ToFrozenDictionary(StringComparer.Ordinal); // 预构建值,读路径直接复用
public bool IsDynamic => false;
public void Initialize(LocalizationResourceInitializationContext context) { }
public LocalizedString? GetOrNull(string cultureName, string name)
=> cultureName == this.cultureName && _entries.TryGetValue(name, out var v) ? v : null;
public void Fill(string cultureName, Dictionary<string, LocalizedString> dictionary)
{
if (cultureName == this.cultureName)
{
foreach (var entry in _entries)
{
dictionary[entry.Key] = entry.Value;
}
}
}
public Task<IEnumerable<string>> GetSupportedCulturesAsync()
=> Task.FromResult<IEnumerable<string>>([cultureName]);
}
4. 只在一段代码里换文化
using (CultureHelper.Use("zh-Hans"))
{
RenderEmail(); // 期间的 localizer[...] 与 string.Format 都按 zh-Hans
}
AsyncLocal 语义,await 之后仍生效,Dispose 还原;不要跨并发工作项共享这个句柄的可见范围。
5. ASP.NET Core:让文化跟着请求走
app.UseRequestLocalization(new RequestLocalizationOptions()
.AddSupportedCultures(["en", "zh-Hans", "tr-TR"])
.AddSupportedUICultures(["en", "zh-Hans", "tr-TR"]));
中间件设好 CurrentCulture / CurrentUICulture,本包只认 CurrentUICulture.Name,不需要再包一层。
6. 可本地化字符串持久化
var serializer = provider.GetRequiredService<ILocalizableStringSerializer>();
string column = serializer.Serialize(LocalizableString.Create<MyResource>("Title"))!; // "L:MyResource,Title"
entity.Title = serializer.Deserialize(column);
string shown = entity.Title.Localize(factory);
| 情况 | 结果 |
|---|---|
Serialize(null) |
null |
无资源归属的 LocalizableString |
"L:,Name";反序列化还原为无资源归属(走默认资源),不是空名字资源 |
| 资源名含逗号 | 第一个逗号即分隔符(L:Res,A,B → res=Res、name=A,B)。资源名不得含逗号 |
L: 后无逗号 / name 空白 |
抛 FormatException("非法的可本地化字符串值:「…」") |
长度 < 3 或 value[1] != ':'(含 "F:"、"L:") |
按固定文本兜底,Deserialize("F:") 得到 "F:" 而非空串 |
不认识的前缀(X:whatever) |
按固定文本兜底 |
既非 LocalizableString 也非 FixedLocalizableString 的实现 |
抛 InvalidOperationException("不支持序列化的 ILocalizableString 类型:…") |
7. 语言下拉框与语言映射
var languages = await provider.GetRequiredService<ILanguageProvider>().GetLanguagesAsync();
var chosen = languages.FindByCulture(requestCulture, requestUiCulture); // 可能为 null
var mapped = options.GetCurrentUICultureLanguagesMap("LeptonX"); // 未命中即原样返回当前语言
var (culture, uiCulture) = LocalizationSettingHelper.ParseLanguageSetting(settingValue);
DefaultLanguageProvider 返回的是 options.Languages 同一实例,UI 层别原地改。
8. 启动即校验资源,而不是等第一个请求
foreach (var name in options.Resources.Keys)
{
var localizer = factory.CreateByResourceName(name);
var cultures = (await localizer.GetSupportedCulturesAsync()).ToArray(); // 空 = 目录没找到或文件全被跳过
_ = localizer["Startup.Probe"]; // 触发构建,坏文件在这里暴露
}
做完这一步,后续取值路径不会再碰文件。
9. 导出整份字典给工具链
var snapshot = localizer.GetAllStrings(includeParentCultures: true)
.ToDictionary(s => s.Name, s => s.Value, StringComparer.Ordinal);
包含父文化与基础资源链的合并结果;只要当前文化的原文就传 includeParentCultures: false。
10. 只要契约层(不引本包)
库/包只依赖 ILocalizableString、IHasNameWithLocalizableDisplayName、ISparkdoStringLocalizerFactory 时,
只引 Sparkdo.Localization.Abstractions。工厂不是 Sparkdo 实现时 OrNull 系列安全降级为 null,
所以库侧写 factory.CreateDefaultOrNull()?.["Key"] 才不绑架宿主。
调用期拒绝清单与异常一览
本包不定义自定义异常类型,全部用 BCL 参数异常 + 中文诊断消息(以源码为准)。
| 异常 | 触发点 | 消息要点 |
|---|---|---|
ArgumentNullException |
AddSparkdoLocalization(null services);SparkdoStringLocalizerFactory / SparkdoEnumLocalizer / SparkdoDictionaryBasedStringLocalizer / JsonFileLocalizationResourceContributor(directoryPath) 构造;LocalizableString / FixedLocalizableString 的 null 文本;扩展方法的 null 工厂/本地化器 |
"Value cannot be null. (Parameter 'x')" |
ArgumentException |
空白字符串入参:资源键 name、resourceName、culture、cultureName、directoryPath、filePath、settingValue、packageName、language;仅分隔符的路径;Deserialize("") |
BCL 英文空白消息;"/" 系为 "目录路径「/」规范化后为空。";Deserialize("") 为 "The value cannot be an empty string. (Parameter 'value')" |
InvalidOperationException |
资源名寻址失败(非 OrNull 版本);Resources.Get 未命中;重复注册同名/同类型;基础资源链成环 |
"未找到资源名为「X」的本地化器。"、"未找到资源名为「X」的资源。"、"未找到资源类型为「X」的资源。"、"资源「X」已注册,不可重复添加。"(类型入口带 AssemblyQualifiedName)、"资源「X」的基础资源声明存在环(继承链:A → B → X;资源不能直接或间接继承自身)。"(继承链为实际环路径) |
InvalidOperationException |
JSON 文件读/解析失败;贡献者未 Initialize 就取值;序列化不认识的实现 |
"非法的本地化文件格式:{路径|文件名}"(InnerException = JsonException)、"文件提供器未初始化:JSON 本地化贡献者需先经 Initialize 完成(目录:X)。"、"不支持序列化的 ILocalizableString 类型:{FullName}" |
FormatException |
Deserialize 的 L: 值缺逗号或 name 空白 |
"非法的可本地化字符串值:「{value}」" |
JsonException |
BuildFromUtf8Bytes 结构/语法非法;顶层空键且其值为标量 |
"本地化 JSON 的键不能为空。";结构错误为 STJ 消息(含 Path/LineNumber/BytePositionInLine) |
ArgumentException |
GetString(enumType: 非枚举) |
"类型「System.String」不是枚举。 (Parameter 'enumType')" |
CultureNotFoundException |
GetBaseCultureName、CultureHelper.Use、LanguageInfo 构造/ChangeCulture 拿到非法文化名 |
"Culture is not supported. (Parameter 'name')" 或运行时消息 |
FormatException |
值含字面花括号且传了格式化参数 | BCL "Input string was not in a correct format. …";零参数索引器不会触发 |
MissingManifestResourceException |
resx 兜底路径直接 GetAllStrings |
BCL/运行时消息;作为基础资源被回退枚举时被吞掉 |
线程安全、缓存与生效时机
- 工厂:
ConcurrentDictionary<string, Lazy<IStringLocalizer>>+ExecutionAndPublication—— 同资源名的首次初始化只执行一次,之后读路径无锁零分配;同一资源反复解析返回同一实例。 - 环守卫:AsyncLocal 初始化栈(
ImmutableStack<string>),只记录当前执行流正在初始化的资源名, 先于Lazy.Value判定,同执行流重入即抛(异常带完整环路径);结束(含异常)恢复原栈,线程上下文无残留。 跨线程并发首次解析同一资源是合法的——由Lazy(ExecutionAndPublication)串行化,不会误报成环。 - 贡献者列表:
List派生,非线程安全。配置期改完再首次解析;首次解析时工厂会向它追加全局贡献者, 因此运行期不要再改已注册资源的贡献者列表。 - 文件贡献者:
volatile引用 + 双检锁构建;变更令牌触发即整体作废、下次访问重建。读并发安全。 - Options:
IOptions<T>首次解析即固定,之后的Configure不重放;Resources/GlobalContributors/Languages/LanguagesMap是可变集合(改注册表不生效),而DefaultResourceType与两个回退开关每次现读。 - 本地化器:无状态(三个只读引用),可长期持有、跨线程共享。
性能特征
BenchmarkDotNet(ShortRun、InProcessEmitToolchain、MemoryDiagnoser;Intel i9-10900K、.NET 11 RC、Release;200 键字典):
| 路径 | Mean | Allocated |
|---|---|---|
| 精确文化命中(基线) | 16.11 ns | 0 B |
| 直接父文化回退命中 | 25.86 ns | 0 B |
| 资源默认文化回退命中 | 18.68 ns | 0 B |
| 工厂缓存解析 | 17.92 ns | 0 B |
| 文件贡献者命中 | 13.80 ns | 0 B |
| 未命中返回标记 | 55.50 ns | 48 B |
| 格式化取值(1 参数) | 50.59 ns | 136 B |
序列化协议 L: |
14.46 ns | 96 B |
| 枚举泛型非装箱(首键命中) | 39.84 ns | 96 B |
| 全量枚举 200 键 | 8,903 ns | 22,336 B |
| JSON 解析 200 键 | 55,040 ns | 117,560 B |
- 命中路径 =
FrozenDictionary查表 + 返回预构建实例,零分配且与键规模无关; 父文化回退多出的 ~10 ns 是一次缓存查名 + 二次查表。 - 未命中比命中贵 3 倍(48 B / 55 ns),别把未命中当常态控制流。
- 全量枚举与 JSON 解析都在构建/工具路径,不进热路径。JSON 解析走 Sparkdo.Json 源生成 context,
比第一版手写
Utf8JsonReader慢约四成(决策记录 36 → 53 µs / 200 键,本次实测 55 µs,JsonElement树物化的代价), 换到的是 json 域策略统一与「无反射 fallback」。它只在首次访问或缓存作废时发生。 - 零分配回归门禁:
LocalizationAllocationTests断言精确命中、父文化回退命中、工厂缓存解析、文件贡献者命中为 0 B。
AOT 立场
- JSON 解析走 Sparkdo.Json 产品域源生成 context(
[SparkdoJsonContext]参与应用级 resolver 聚合): 无反射序列化器、无Dictionary<string, object>装箱、无反射 fallback。 - 全局贡献者为委托工厂(
AddGlobalContributor<T>()),无Activator.CreateInstance。 - 反射仅剩资源名/继承特性的特性读取(按类型缓存、配置与首次解析期一次性)。
IsAotCompatible+IsTrimmable,分析器零告警。
常见坑
| 坑 | 事实 |
|---|---|
Resources.Add("MyApp") 后注入 IStringLocalizer<MyAppResource> 拿不到值 |
按名注册不进类型索引,类型入口静默落 resx |
设了 DefaultResourceType 却没 Add 该类型 |
CreateDefaultOrNull() 抛 InvalidOperationException(fail-closed);显式 AllowUnregisteredDefaultResource = true 才回退 resx 旧行为——届时默认资源寻址、DisplayName: 链、LocalizableString 兜底全部读不到字典 |
期望 zh-Hans-CN 兜到 zh |
父文化只上溯一级;zh 层要靠资源默认文化 |
当前文化 en,期望回退拿到 en-US 的内容 |
回退方向只有「具体 → 父」 |
关掉 TryToGetFromBaseCulture 后 GetAllStrings(true) 里仍有父文化键 |
全量枚举的父文化层由 includeParentCultures 控制,与那两个开关无关 |
以为 IsDynamic 会让单键取值跳过动态贡献者 |
单键路径永远会问它,includeDynamicContributors 只作用于全量枚举 |
JSON 写 "culture": "EN" 而运行文化是 en |
文化名按 Ordinal 比较,字典文化名必须与 CultureInfo.Name 完全一致 |
| 指望空键被校验拦住 | 只有「顶层空键 + 标量值」抛错;{"a":{"":"x"}} 安静产出键 a__,{"":{"a":"x"}} 直接产出键 a |
| 一个 JSON 文件写坏,第一个请求才炸 | 解析在首次取值时发生;启动跑配方 8 |
路径写成 /Loc 就以为是绝对路径 |
构造期归一成 Loc;"/"、"\\" 这类归一后为空的路径直接抛 ArgumentException |
自己 new 文件贡献者就直接取值 |
必须先 Initialize(context) |
localizer["{0} 已就绪", x] |
字面花括号 + 传参 → FormatException;零参数索引器才安全 |
期待 SearchedLocation 指出命中层 |
字典模型恒为 null |
资源名放逗号后用 L: 持久化 |
第一个逗号即分隔符,后半段被当成 name |
Add<T>() 调两次或两个类型撞同名标注 |
抛 InvalidOperationException("…已注册,不可重复添加。") |
| 基础资源名拼错 | 无警告,那一级直接不存在 |
| 资源继承声明成环 | 首次解析即抛,消息含"环" |
运行期改 options.Resources |
IOptions<T> 已固定,改注册表不生效;DefaultResourceType 与两个开关改了立刻生效 |
原地修改 GetLanguagesAsync() 的返回值 |
默认实现返回 options.Languages 同一实例,会污染配置 |
想让 IExternalLocalizationStore 参与 Create(Type) |
外部存储只在按名寻址路径被问 |
| 外部存储每次返回新的资源副本 | 全局贡献者会被追加进 Contributors,副本每次新 → 条目不断累积 |
| 期待本包发"语言已变更"事件 | 事件总线绑定已裁剪 |
与其他包的关系
- 依赖:
Sparkdo.Localization.Abstractions(契约)、Microsoft.Extensions.Localization(ResourceManagerStringLocalizerFactory,标准 resx 回退所在包)、Microsoft.Extensions.FileProviders.Physical(宿主未注册IFileProvider时的物理回退)、Sparkdo.Json(JSON 字典解析与应用级 resolver 聚合)。 DI / Options / FileProviders.Abstractions / Logging.Abstractions 由 net11.0 隐式提供;net10.0 分支按框架条件显式声明。 - 与
Sparkdo.VirtualFileSystem组合:VFS 注册的IFileProvider直接作为 JSON 字典文件来源,无需适配层。 - 与
Sparkdo.Localization.Abstractions的分工:ILocalizableString家族、资源命名与继承特性、 显示名扩展、ISparkdoStringLocalizerFactory工厂缝在契约包;本包是字典、工厂、本地化器、 JSON 与文件贡献者、枚举、语言模型、序列化器与 DI 接线。 - 与 ABP(
Volo.Abp.Localization)的关系:本包是其产品化移植(非 1:1)——模块系统 → DI 接线、 VFS 依赖 → 标准IFileProvider缝、Settings/EventBus/动态代理裁剪、Activator→ 委托工厂、 反射序列化 → 源生成 UTF-8 直解析;行为修正与完整决策记录见仓库src/localization/docs/port-analysis.md。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
- Microsoft.Extensions.FileProviders.Abstractions (>= 10.0.8)
- Microsoft.Extensions.FileProviders.Physical (>= 10.0.8)
- Microsoft.Extensions.Localization (>= 10.0.8)
- Microsoft.Extensions.Localization.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Options (>= 10.0.8)
- Microsoft.Extensions.Primitives (>= 10.0.8)
- Sparkdo.Json (>= 3.0.0-preview.1)
- Sparkdo.Localization.Abstractions (>= 3.0.0-preview.1)
-
net11.0
- Microsoft.Extensions.FileProviders.Physical (>= 10.0.8)
- Microsoft.Extensions.Localization (>= 10.0.8)
- Microsoft.Extensions.Localization.Abstractions (>= 10.0.8)
- Sparkdo.Json (>= 3.0.0-preview.1)
- Sparkdo.Localization.Abstractions (>= 3.0.0-preview.1)
NuGet packages (7)
Showing the top 5 NuGet packages that depend on Sparkdo.Localization:
| Package | Downloads |
|---|---|
|
Sparkdo.ExceptionHandling
Sparkdo 异常元数据解析、观察分发、问题投影与调用边界运行时。 |
|
|
Sparkdo.Ddd.Application.Contracts
Sparkdo DDD应用层契约库,提供应用服务接口定义和数据传输对象(DTO)基类 |
|
|
Sparkdo.Features
Sparkdo 功能系统库,提供功能定义、管理、检查和拦截功能 |
|
|
Sparkdo.GlobalFeatures
Sparkdo 全局特性库,提供应用程序级别的特性开关管理和模块化特性控制功能 |
|
|
Sparkdo.SettingManagement.Domain.Shared
Sparkdo 设置管理领域共享库,提供设置管理模块的共享资源、常量定义和数据传输对象 |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-preview.1 | 52 | 10/30/2026 |
| 2.0.0-preview.2 | 111 | 7/20/2026 |
| 2.0.0-preview.1 | 95 | 7/18/2026 |
| 1.0.4-preview.3 | 480 | 6/8/2026 |
| 1.0.4-preview.2 | 463 | 6/7/2026 |
| 1.0.4-preview.1 | 439 | 6/7/2026 |
| 1.0.3 | 1,105 | 6/6/2026 |
| 1.0.3-preview.1 | 456 | 5/17/2026 |
| 1.0.2 | 824 | 4/27/2026 |
| 1.0.2-preview.5 | 196 | 4/15/2026 |
| 1.0.2-preview.4 | 201 | 2/8/2026 |
| 1.0.2-preview.3 | 214 | 2/1/2026 |
| 1.0.2-preview.2 | 219 | 1/31/2026 |
| 1.0.2-preview.1 | 263 | 12/4/2025 |
| 1.0.1 | 2,959 | 11/27/2025 |
| 1.0.0 | 2,863 | 11/25/2025 |
| 1.0.0-preview.5 | 185 | 10/24/2025 |