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.Emailing

Sparkdo 邮件发送库,提供基于 SMTP 的邮件发送功能,支持同步/异步发送、后台队列发送和模板邮件

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0-preview.1 55 10/30/2026
2.0.0-preview.2 114 7/20/2026
2.0.0-preview.1 97 7/18/2026
1.0.4-preview.3 481 6/8/2026
1.0.4-preview.2 464 6/7/2026
1.0.4-preview.1 440 6/7/2026
1.0.3 1,187 6/6/2026
1.0.3-preview.1 460 5/17/2026
1.0.2 827 4/27/2026
1.0.2-preview.5 198 4/15/2026
1.0.2-preview.4 203 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,960 11/27/2025
1.0.0 2,863 11/25/2025
1.0.0-preview.5 185 10/24/2025