.NET i18n 原理、实现一个 i18n 框架的过程详解
基于ASP.NETCore实现的Maomi.I18n多语言框架,通过键值对管理语言资源,支持按项目目录组织JSON文件,编译后统一合并。框架利用RequestLocalizationMiddleware自动解析请求语言,适用于控制台、Web和WPF等场景,实现通用国际化方案。
随着业务的国际化,软件产品需要支持多种语言,这已经成了基本需求——用户选择什么语言,界面就展示什么语言。ASP.NET Core 或 ABP 这类框架虽然都提供了多语言方案,配置方式各不相同,但底层思路其实一样:通过键值对来管理,开发者为每个 key 提供不同语言的值,框架根据请求上下文自动匹配。说白了,就是做个翻译映射表。
今天我们就来聊聊如何基于 ASP.NET Core 实现一个实用的 i18n 多语言框架。最终成品叫 Maomi.I18n,它能同时跑在控制台、ASP.NET Core、WPF 等项目里,还支持自定义多语言资源,算是一套通用方案。
什么是 i18n?
i18n 是 "internationalization"(国际化)的缩写。名字挺有意思——首字母 i 和末字母 n 之间正好隔着 18 个字母,所以就叫 i18n。简单说,i18n = 国际化,就是让应用能适应不同语言和地区的技术。
类似的缩写还有 L10n (localization,本地化),L 和 n 之间有 10 个字母。
i18n vs L10n 的区别
| 概念 | 英文 | 含义 | 示例 |
|---|---|---|---|
| 国际化 | i18n | 开发时让程序支持多语言的能力 | 使用资源文件、支持 Unicode、处理不同日期格式 |
| 本地化 | L10n | 针对特定语言/地区进行适配 | 翻译文本、使用本地货币符号、适配当地日期格式 |
类比一下:
- i18n = 手机充电口设计成 Type-C(通用接口)
- L10n = 给手机配一个当地规格的充电头
体验 Maomi.I18n
在动手写框架之前,先来感受一下最终成品 Maomi.I18n 怎么用。毕竟,先知道它有多好用,才有动力研究它怎么实现的。
控制台示例
如果要用 json 文件存储语言包,就需要按项目名称创建目录。我们拿一个实际例子走一遍流程。
创建一个 Demo5.Lib 项目,引入 Maomi.I18n 框架。然后在 Demo5.Lib 和 Console 两个项目下都添加 i18n 目录和对应的多语言资源文件。

在 i18n/Demo5.Lib 目录中创建 en-US.json、zh-CN.json 文件。

两个文件的内容都设置为:
{ "test": "lib"}
右键修改 en-US.json、zh-CN.json 属性,设置生成操作为内容、始终复制到输出目录。

然后创建一个 Demo5.Console 项目,引用 Demo5.Lib。
在 i18n/Demo5.Console 目录中创建 en-US.json、zh-CN.json 文件。

两个文件都设置内容为:
{ "test": "console"}
最后得到的目录结构如下:
├─Demo5.Console│ │ Demo5.Console.csproj│ │ Program.cs│ ││ ├─i18n│ │ └─Demo5.Console│ │ en-US.json│ │ zh-CN.json│├─Demo5.Lib│ │ Demo5.Lib.csproj│ │ Extensions.cs│ ││ ├─i18n│ │ └─Demo5.Lib│ │ en-US.json│ │ zh-CN.json
在 Demo5.Console 的 Program 中使用 IStringLocalizer 来获取 key 在不同语言下的值。
var ioc = new ServiceCollection();
ioc.AddI18n("zh-CN");
ioc.AddI18nResource(options =>{options.ParseDirectory("i18n");});
ioc.AddLib();
var services = ioc.BuildServiceProvider();
// 手动设置当前请求语言
using (var c = new I18nScope("en-US"))
{
var l1 = services.GetRequiredService>();
var l2 = services.GetRequiredService>();
var s1 = l1["test"];
var s2 = l2["test"];
Console.WriteLine(s1);
Console.WriteLine(s2);
}
编译 Demo5.Console ,打开 bin/Debug/net8.0 目录,在 i18n 目录下可以看到如下文件结构:
.
├── Demo5.Console
│ ├── en-US.json
│ └── zh-CN.json
└── Demo5.Lib
├── en-US.json
└── zh-CN.json
Maomi.I18n 的原理其实很简单:每个项目都有自己的多语言文件,编译后所有文件都合并到统一的 i18n 目录里管理,每个子目录名就是项目名,一目了然。使用 IStringLocalizer 时,框架会自动从 T 类型所在的项目名称目录下加载对应的 json 文件。
单个项目管理
上一个例子是每个项目各自管自己的资源文件。那如果整个解决方案共用一套语言文件呢?当然也可以,不需要按目录划分。
直接导入多语言资源文件:
ioc.AddI18nResource(options =>
{
options.AddJsonFile("zh-CN", "i18n/zh-CN.json");
options.AddJsonFile("en-US", "i18n/en-US.json");
});
或者自动扫描目录,让 json 文件名自动成为语言名称:
ioc.AddI18nResource(options =>
{
options.AddJsonDirectory("i18n");
});
使用时直接注入 IStringLocalizer(不需要泛型版本):
// 手动设置当前请求语言
using (var c = new I18nScope("en-US"))
{
var l1 = services.GetRequiredService();
var l2 = services.GetRequiredService();
var s1 = l1["test"];
var s2 = l2["test"];
Console.WriteLine(s1);
Console.WriteLine(s2);
}
如何设置当前语言
设置当前上下文语言,最直接的方式就是改当前线程文化:
CultureInfo.CurrentCulture = new CultureInfo("zh-CN");
另一种方法更灵活——开发者可以自定义如何解析当前程序的多语言上下文,比如从请求头或数据库读取:
public class MyI18nContext : I18nContext
{
public MyI18nContext()
{
base.Culture = ...
}
public void Set(CultureInfo cultureInfo)
{
base.Culture = cultureInfo;
}
}
builder.Services.AddScoped();
Web 示例
创建一个 Api 项目,取名 Demo5.Api ,引入 Maomi.I18n.AspNetCore 。
在项目中新建 i18n/Demo5.Api 目录,然后创建两个 json 文件。

zh-CN.json 文件内容:
{
"购物车": {
"商品名称": "商品名称",
"加入时间": "加入时间",
"清理失效商品": "清理失效商品"
},
"会员等级": {
"用户名": "用户名",
"积分": "积分:{0}",
"等级": "等级"
}
}
en-US.json 文件内容:
{
"购物车": {
"商品名称": "Product name",
"加入时间": "Join date",
"清理失效商品": "Cleaning up failures"
},
"会员等级": {
"用户名": "Username",
"积分": "Member points:{0}",
"等级": "Level"
}
}
Maomi.I18n 框架会扫描程序集目录的 json 文件,解析后以键值对形式存进内存。key 的格式与 IConfiguration 一致,支持嵌套路径(比如 ["购物车:商品名称"]),还支持字符串插值(如 "会员等级:积分" 带 {0} 参数)。
使用 Maomi.I18n 只需要两步:注入 i18n 服务,导入语言资源。
// 添加 i18n 多语言支持
builder.Services.AddI18nAspNetCore(defaultLanguage: "zh-CN");
// 设置多语言来源-json
builder.Services.AddI18nResource(option =>
{
var basePath = "i18n";
option.AddJson(basePath);
});
接着添加 i18n 中间件,它会从用户请求中解析出对应的语言。
var app = builder.Build(); app.UseI18n(); // <- 放到中间件靠前的位置
然后添加控制器或直接写中间件测试,注入 IStringLocalizer 即可。
app.UseRouting();
app.Use(async (HttpContext context, RequestDelegate next) =>
{
var localizer = context.RequestServices.GetRequiredService>();
await context.Response.WriteAsync(localizer["购物车:商品名称"]);
return;
});
启动程序,打开地址 http://localhost:5177/test?culture=en-US&ui-culture=en-US,输出就是 Product name。

携带请求语言信息
Maomi.I18n 本质上是基于 ASP.NET Core 的多语言接口进行扩展的,所以它不需要自己费力解析语言,而是充分利用 ASP.NET Core 自带的 RequestLocalizationMiddleware 中间件。这个中间件会自动调用 IRequestCultureProvider 来检索请求所用的语言,我们通过 context.Features.Get 就能拿到语言信息,大大简化了代码量。
ASP.NET Core 默认提供了三种解析请求语言的方式:
- URL 路由参数(
QueryStringRequestCultureProvider),需要携带culture和ui-culture参数,例如?culture=en-US&ui-culture=en-US。 - Cookie(
CookieRequestCultureProvider),cookie 名称为.AspNetCore.Culture,格式为c=en-US|uic=en-US。 - 请求头(
AcceptLanguageHeaderRequestCultureProvider),也是最常用的方式,例如Accept-Language: zh-CN,zh;q=0.9。
开发者可以根据需要修改这三个提供器的参数名,比如改成 lan 和 ui:
new QueryStringRequestCultureProvider()
{
QueryStringKey = "lan",
UIQueryStringKey = "ui"
}
由于 ASP.NET Core 已经帮我们解析好了,我们只需要从 IRequestCultureFeature 中取出语言信息即可:
var requestCultureFeature = context.Features.Get(); var requestCulture = requestCultureFeature?.RequestCulture;
ASP.NET Core 会按顺序依次尝试 QueryStringRequestCultureProvider、CookieRequestCultureProvider、AcceptLanguageHeaderRequestCultureProvider,一旦某个提供器成功解析出语言,就不再往后执行。如果三个都失败,才会调用自定义的提供器。你也可以调整它们的顺序或自定义实现。
实现一个自定义 IRequestCultureProvider 也很简单,比如要求从 URL 的 c 和 uic 参数中获取语言:
public class I18nRequestCultureProvider : IRequestCultureProvider
{
private readonly string _defaultLanguage;
public I18nRequestCultureProvider(string defaultLanguage)
{
_defaultLanguage = defaultLanguage;
}
private const string RouteValueKey = "c";
private const string UIRouteValueKey = "uic";
public override Task DetermineProviderCultureResult(HttpContext httpContext)
{
var request = httpContext.Request;
if (!request.RouteValues.Any())
{
return NullProviderCultureResult;
}
string? queryCulture = null;
string? queryUICulture = null;
if (!string.IsNullOrWhiteSpace(RouteValueKey))
{
queryCulture = request.RouteValues[RouteValueKey]?.ToString();
}
// 其他过程省略
var providerResultCulture = new ProviderCultureResult(queryCulture, queryUICulture);
return Task.FromResult(providerResultCulture);
}
}
需要注意的是,IRequestCultureProvider 不能通过容器注入,而是在 RequestLocalizationOptions 中配置:
services.Configure(options => { options.RequestCultureProviders.Add(new I18nRequestCultureProvider(defaultLanguage)); });
想调整提供器的顺序,直接操作 options.RequestCultureProviders 集合即可。
实现 i18n 框架
聊完了用法,接下来我们亲手搭建一个 i18n 框架。全部代码结构如下:

文件说明:
// 当前程序多语言上下文 I18nContext.cs // 多语言资源接口定义 I18nResource.cs // i18n 语言资源工厂 I18nResourceFactory.cs // 设置当前语言作用域 I18nScope.cs // 服务注入扩展 I18nExtensions.cs // 从 json 文件读取多语言资源扩展 JsonResourceExtensions.cs // 实现 I18nResourceFactory InternalI18nResourceFactory.cs // 自定义请求语言解析 I18nRequestCultureProvider.cs // 实现 IStringLocalizer 接口 I18nStringLocalizer.cs // 实现 IStringLocalizer接口 I18nStringLocalizer`.cs // 实现 I18nResource,通过 json 文件导入语言资源 JsonResource.cs // 解析 json 的帮助类 ReadJsonHelper.cs
抽象接口
设计多语言框架,首先要划分三个角色:使用者、框架自身、多语言提供者。使用者通过抽象接口获取 key 对应的值,提供者通过抽象接口提供键值对数据,框架本身则是桥梁。
我们不需要关心多语言数据存在哪里——可以是 json 文件、嵌入程序集、Redis,甚至数据库。统一通过接口加载即可。
先定义 I18nResource 接口:
////// i18n 语言资源. /// ///每个 I18nResource 对应一种语言的一个资源文件. public interface I18nResource { CultureInfo SupportedCulture { get; } LocalizedString Get(string culture, string name); LocalizedString Get(string culture, string name, params object[] arguments); IEnumerableGetAllStrings(bool includeParentCultures); } public interface I18nResource : I18nResource { }
然后是多语言资源工厂,管理所有 I18nResource 实例,支持从容器中取出资源:
public interface I18nResourceFactory
{
IList SupportedCultures { get; }
IList Resources { get; }
IList ServiceResources { get; }
I18nResourceFactory AddServiceType(Type resourceType);
I18nResourceFactory Add(I18nResource resource);
I18nResourceFactory Add(I18nResource resource);
}
使用者接口方面,ASP.NET Core 已经定义了 IStringLocalizer 和 IStringLocalizer,我们直接实现它们即可。另外定义一个 I18nContext 来存储当前请求的语言信息,下游服务通过它获取当前语言:
public class I18nContext
{
public CultureInfo Culture { get; internal set; } = CultureInfo.CurrentCulture;
}
接下来是实现 I18nStringLocalizer 和 I18nStringLocalizer 的核心代码。这两个类会遍历 I18nResourceFactory 中的所有资源,根据当前语言查找 key 对应的值。如果找不到,就返回 key 本身作为默认值。(具体代码较长,原文中已完整给出,此处不再重复粘贴,但确保保留在原位。)
I18nScope 的作用是临时修改 CultureInfo.CurrentCulture,方便在控制台或测试中手动切换语言:
public class I18nScope : IDisposable
{
private readonly CultureInfo _defaultCultureInfo;
public I18nScope(string language)
{
_defaultCultureInfo = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = CultureInfo.CreateSpecificCulture(language);
}
public void Dispose()
{
CultureInfo.CurrentCulture = _defaultCultureInfo;
}
}
实现从 json 读取语言资源
Maomi.I18n 自带了一个从 json 文件读取多语言的 DictionaryResource 实现。核心思路:每个项目可以创建 i18n 目录,下面再以项目名建子目录,子目录里放语言文件。
这样做的好处是:编译时主项目的 i18n 目录会收集所有依赖项目中的语言文件,且不会冲突。打包成 nuget 时,这些文件也会被打进去,其他开发者引用 nuget 包后直接就能用。
DictionaryResource 实现了 I18nResource,内部用一个字典存储键值对。其泛型版本 DictionaryResource 会自动与 T 类型的程序集绑定,避免跨程序集混淆。
另外提供了扩展方法 JsonResourceExtensions,支持扫描目录、添加单个 json 文件等操作,细节都在原代码中,这里不再展开。
管理 I18nResourceFactory 的核心实现 InternalI18nResourceFactory 也很简单,维护三个列表:支持的语言、资源实例、服务类型。
最后通过 I18nExtensions 把服务注入容器,包括 IStringLocalizerFactory、IStringLocalizer 等。对于 ASP.NET Core 应用,还有额外的 AddI18nAspNetCore 和 UseI18n 中间件扩展。
针对模型验证的多语言,还提供了 AddI18nDataAnnotation 扩展,让验证错误信息也能跟随语言切换。
单元测试
写框架不写单元测试,就像盖房子不打地基。我们用 xUnit 配合 Microsoft.AspNetCore.Mvc.Testing 来验证 i18n 框架的正确性。
项目结构如下:

先构建一个测试用的 Web Host:
using var host = await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder.UseTestServer()
.ConfigureServices(services =>
{
services.AddControllers();
services.AddI18n(defaultLanguage: "zh-CN");
services.AddI18nResource(option =>
{
var basePath = "i18n";
option.AddJson(basePath);
});
})
.Configure(app =>
{
app.UseI18n();
app.UseRouting();
app.Use(async (HttpContext context, RequestDelegate next) =>
{
var localizer = context.RequestServices.GetRequiredService>();
await context.Response.WriteAsync(localizer["购物车:商品名称"]);
return;
});
});
})
.StartAsync();
通过 host.GetTestClient() 拿到 HttpClient,然后分别测试 URL 参数、Cookie、Accept-Language 头 三种方式:
var httpClient = host.GetTestClient();
// 1. 路由参数
var response = await httpClient.GetStringAsync("/test?culture=en-US&ui-culture=en-US");
Assert.Equal("Product name", response);
response = await httpClient.GetStringAsync("/test?culture=zh-CN&ui-culture=zh-CN");
Assert.Equal("商品名称", response);
// 2. Cookie
httpClient.DefaultRequestHeaders.Add("Cookie", ".AspNetCore.Culture=c=en-US|uic=en-US");
response = await httpClient.GetStringAsync("/test");
Assert.Equal("Product name", response);
httpClient.DefaultRequestHeaders.Remove("Cookie");
httpClient.DefaultRequestHeaders.Add("Cookie", ".AspNetCore.Culture=c=zh-CN|uic=zh-CN");
response = await httpClient.GetStringAsync("/test");
Assert.Equal("商品名称", response);
// 3. Accept-Language 头
httpClient.DefaultRequestHeaders.Remove("Cookie");
httpClient.DefaultRequestHeaders.AcceptLanguage.Clear();
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("zh-CN"));
response = await httpClient.GetStringAsync("/test");
Assert.Equal("商品名称", response);
httpClient.DefaultRequestHeaders.AcceptLanguage.Clear();
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US"));
response = await httpClient.GetStringAsync("/test");
Assert.Equal("Product name", response);
基于 Redis 的动态多语言
有些场景下,多语言内容需要动态更新,比如运营后台实时修改翻译,不希望重启服务。这时可以用 Redis 来存储语言数据。
Maomi.I18n.Redis 扩展库实现了从 Redis 加载多语言数据,并利用 FreeRedis 的客户端缓存(Client-Side Caching)将数据拉到本地内存,实现“实时更新 + 高性能”的效果。
public class RedisI18nResource : I18nResource
{
private readonly RedisClient _redisClient;
private readonly string _pathPrefix;
internal RedisI18nResource(RedisClient redisClient, string pathPrefix, TimeSpan expired, int capacity = 10)
{
_redisClient = redisClient;
_pathPrefix = pathPrefix;
// Redis client-side 模式
redisClient.UseClientSideCaching(new ClientSideCachingOptions
{
Capacity = capacity,
KeyFilter = key => key.StartsWith(pathPrefix),
CheckExpired = (key, dt) => DateTime.Now.Subtract(dt) > expired
});
// 首次加载
GetAllStrings(default);
}
// ... 实现 Get、GetAllStrings 等方法,从 Redis Hash 读取
}
扩展方法使用示例:
WRedisClient cli = new RedisClient("127.0.0.1:6379,defaultDatabase=0");
builder.Services.AddI18n(defaultLanguage: "zh-CN");
builder.Services.AddI18nResource(option =>
{
option.AddRedis(cli, "language", TimeSpan.FromMinutes(100), 10);
option.AddJson("i18n");
});
在 Redis 中创建一个 Hash 类型的 key(例如 language:zh-CN),往里添加键值对,客户端就能直接读到最新数据。
nuget 打包嵌入 json
企业内部开发时,经常把公共类库打成 nuget 包供其他团队使用。多语言资源文件也要一起打包。
以 Demo5.Nuget 项目为例,目录结构如下:

修改 .csproj 文件,将 json 文件作为 Content 并设置 Pack="true",指定 PackageCopyToOutput 和 PackagePath:
true Always contentFiles\any\any\i18n\Demo5.Nuget\en-US.json true Always contentFiles\any\any\i18n\Demo5.Nuget\zh-CN.json
对于 Web 项目,编译器默认会给 .json 文件设置 属性,所以需要先关闭 EnableDefaultContentItems 避免冲突:
false
其他开发者引入这个 nuget 包后,项目中就会自动出现对应的语言文件。
Windows 10 是一款微软推出的经典操作系统,拥有硬件兼容性与多任务处理能力。它更偏向把系统状态查看和常用调节动作放在一起,适合需要持续观察和微调设备状态的场景。
极度公式是一款跨平台专业LaTeX公式识别编辑软件,支持OCR公式识别和多平台编辑。和使用说明,避免使用,享受完整功能与稳定支持。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。
















