Sparkdo.Localization 3.0.0-preview.1

This is a prerelease version of Sparkdo.Localization.
dotnet add package Sparkdo.Localization --version 3.0.0-preview.1
                    
NuGet\Install-Package Sparkdo.Localization -Version 3.0.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sparkdo.Localization" Version="3.0.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sparkdo.Localization" Version="3.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Sparkdo.Localization" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sparkdo.Localization --version 3.0.0-preview.1
                    
#r "nuget: Sparkdo.Localization, 3.0.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sparkdo.Localization@3.0.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sparkdo.Localization&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Sparkdo.Localization&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Tool

Sparkdo.Localization

字典式本地化的运行时包:资源(Resource)→ 贡献者(Contributor)→ 字典(Dictionary)三级模型, 按「精确文化 → 直接父文化 → 资源默认文化 → 基础资源链」回退取值;JSON 字典文件经 Sparkdo.Json 产品域源生成 context 直读 UTF-8(无反射 fallback、AOT 友好),文件来源是标准 IFileProvider。 AddSparkdoLocalization() 一行接线并替换标准 IStringLocalizerFactory,裸 ServiceCollection 零前置注册。

数据流:资源注册(Options)→ 首次 Create 懒初始化贡献者 → FrozenDictionary 文化字典 → 标准 IStringLocalizer 消费。

目录

快速开始

资源类 + 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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