.NET Core 本地化 (L10n)

本地化(Localization,简称 L10n)是指将软件产品针对特定区域市场的语言、文化习俗及技术标准进行适配的过程。它建立在国际化(I18n)的基础之上。在 .NET 中,这主要涉及两个核心概念:

  • Culture (文化)‌:影响数字、日期、时间、货币等数据的格式化显示(例如 en-US 使用 $,zh-CN 使用 ¥)。
  • UICulture (UI 文化):影响资源文件(如字符串翻译)的查找和加载。

本地化的目标是让应用程序能够根据用户的偏好,动态展示相应的文本和格式,从而提升用户体验。

多语言支持 (Minimal APIs + .resx)

这是 .NET 最传统的本地化方式。通过 .resx (Resource XML) 文件存储键值对翻译。在 Minimal API 中,我们需要注册 AddLocalization 服务,并注入 IStringLocalizer<T> 来获取翻译后的字符串。

步骤:

创建资源文:

  • 在项目根目录创建 Resources 文件夹。
  • 创建 SharedResource.resx (默认/英文)。
  • 创建 SharedResource.zh-CN.resx (中文)。
  • 创建一个空类 SharedResource.cs 用于定位资源。

SharedResource.resx 内容:

Name Value
Hello Hello World

SharedResource.zh-CN.resx 内容:

Name Value
Hello 你好世界

Program.cs 配置:

cs 复制代码
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Localization;

var builder = WebApplication.CreateBuilder(args);

// 1. 添加本地化服务,指定资源路径为 "Resources"
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

// 2. 配置支持的语言
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    var supportedCultures = new[] { "en-US", "zh-CN" };
    options.SetDefaultCulture(supportedCultures[0])
           .AddSupportedCultures(supportedCultures)
           .AddSupportedUICultures(supportedCultures);
});

var app = builder.Build();

// 3. 启用请求本地化中间件
app.UseRequestLocalization();

// 4. 使用 IStringLocalizer 获取翻译
app.MapGet("/", (IStringLocalizer<SharedResource> localizer) =>
{
    return Results.Ok(new { Message = localizer["Hello"] });
});

app.Run();

// 标记类,用于定位资源文件
public class SharedResource { }

RequestLocalizationMiddleware 中间件

UseRequestLocalization() 中间件负责检测当前请求的文化特性(Culture)。它内部维护了一个 RequestCultureProvider 列表,按顺序尝试确定用户的语言偏好。一旦某个提供者成功识别,后续提供者将被跳过。

检测顺序:

QueryStringRequestCultureProvider URL 参数 (e.g., ?culture=zh-CN)。
CookieRequestCultureProviderCookie (e.g., .AspNetCore.Culture)。
AcceptLanguageHeaderRequestCultureProvider HTTP Header (Accept-Language)。

示例 (Program.cs):

cs 复制代码
// 在 Program.cs 中启用中间件
// 注意:必须放在任何可能依赖 Culture 的中间件之前
app.UseRequestLocalization();

// 此时访问 http://localhost:5000/?culture=zh-CN 
// 中间件会自动将 CurrentCulture 和 CurrentUICulture 设置为 zh-CN

RouteDataRequestCultureProvider

RouteDataRequestCultureProvider 允许通过 URL 路由数据来确定语言。例如,URL 结构为 /zh-CN/home 或 /en-US/home。这需要自定义提供者顺序,并确保路由模板中包含文化参数。

PS: 标准的 RouteDataRequestCultureProvider 查找路由数据中的 culture 和 ui-culture 键。

示例:

配置提供者顺序:

cs 复制代码
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    var supportedCultures = new[] { "en-US", "zh-CN" };
    options.SetDefaultCulture(supportedCultures[0])
           .AddSupportedCultures(supportedCultures)
           .AddSupportedUICultures(supportedCultures);

    // 清除默认提供者,将 RouteData 提供者置顶
    options.RequestCultureProviders.Clear();
    options.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider());
    // 可选:保留其他提供者作为 fallback
    options.RequestCultureProviders.Add(new QueryStringRequestCultureProvider());
});

定义包含文化的路由:

cs 复制代码
// 路由模板中包含 {culture}
app.MapGet("/{culture}/hello", (string culture, IStringLocalizer<SharedResource> localizer) =>
{
    // 在 Minimal API 中,RouteDataRequestCultureProvider 会自动从路由数据中提取 culture
    // 并设置 HttpContext 中的 CultureInfo。
    // 如果自动设置未生效(取决于版本),可能需要手动设置或使用 Action Filter。
    // 但在标准配置下,中间件会处理此逻辑。
    
    return Results.Ok(localizer["Hello"]);
});

访问 http://localhost:5000/zh-CN/hello 将显示中文。

自定义 RequestCultureProvider 类

当默认策略(Query, Cookie, Header, Route)无法满足需求时(例如从 JWT Token、自定义 Header X-Lang 或数据库中读取用户偏好),可以创建自定义提供者。需继承 RequestCultureProvider 并重写 DetermineProviderCultureResult 方法。

示例:

创建自定义提供者类:

cs 复制代码
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Http;

public class CustomHeaderRequestCultureProvider : RequestCultureProvider
{
    public override Task<ProviderCultureResult?> DetermineProviderCultureResult(HttpContext httpContext)
    {
        if (httpContext == null)
            throw new ArgumentNullException(nameof(httpContext));

        // 从自定义 Header "X-Custom-Lang" 中读取语言代码
        var langHeader = httpContext.Request.Headers["X-Custom-Lang"].ToString();

        if (string.IsNullOrEmpty(langHeader))
        {
            // 返回 null 表示此提供者无法确定文化,中间件将尝试下一个提供者
            return Task.FromResult<ProviderCultureResult?>(null);
        }

        // 返回解析出的文化结果
        return Task.FromResult<ProviderCultureResult?>(new ProviderCultureResult(langHeader));
    }
}

注册自定义提供者:

cs 复制代码
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    var supportedCultures = new[] { "en-US", "zh-CN" };
    options.SetDefaultCulture(supportedCultures[0])
           .AddSupportedCultures(supportedCultures)
           .AddSupportedUICultures(supportedCultures);

    // 将自定义提供者插入到列表最前面,优先执行
    options.RequestCultureProviders.Insert(0, new CustomHeaderRequestCultureProvider());
});

多样化的数据源

除了 .resx,我们可以实现 IStringLocalizer 和 IStringLocalizerFactory 来支持 JSON 文件或 Redis。

基于 JSON 格式的本地化

创建一个 JSON 文件存储所有语言的翻译,并通过自定义 Localizer 读取。适合小型应用或静态配置。

创建 JSON 文件 (Resources/lang.json):

bash 复制代码
{
  "zh-CN": {
    "Hello": "你好,JSON!",
    "Welcome": "欢迎"
  },
  "en-US": {
    "Hello": "Hello from JSON!",
    "Welcome": "Welcome"
  }
}

实现 JSON Localizer:

cs 复制代码
using Microsoft.Extensions.Localization;
using System.Text.Json;

public class JsonStringLocalizer : IStringLocalizer
{
    private readonly Dictionary<string, string> _resources;

    public JsonStringLocalizer(string cultureName)
    {
        _resources = LoadResources(cultureName);
    }

    private Dictionary<string, string> LoadResources(string cultureName)
    {
        var path = Path.Combine("Resources", "lang.json");
        if (!File.Exists(path)) return new Dictionary<string, string>();

        var json = File.ReadAllText(path);
        var allLangs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string>>>(json);
        
        // 尝试获取指定语言,否则回退到英文,再否则为空
        if (allLangs != null)
        {
            if (allLangs.ContainsKey(cultureName)) return allLangs[cultureName];
            if (allLangs.ContainsKey("en-US")) return allLangs["en-US"];
        }
        return new Dictionary<string, string>();
    }

    public LocalizedString this[string name]
    {
        get
        {
            if (_resources.TryGetValue(name, out var value))
                return new LocalizedString(name, value, false);
            return new LocalizedString(name, name, true); // Not found
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var format = this[name].Value;
            return new LocalizedString(name, string.Format(format, arguments), false);
        }
    }

    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        return _resources.Select(kvp => new LocalizedString(kvp.Key, kvp.Value));
    }
}

public class JsonStringLocalizerFactory : IStringLocalizerFactory
{
    public IStringLocalizer Create(Type resourceSource)
    {
        return new JsonStringLocalizer(CultureInfo.CurrentUICulture.Name);
    }

    public IStringLocalizer Create(string baseName, string location)
    {
        return new JsonStringLocalizer(CultureInfo.CurrentUICulture.Name);
    }
}

注册服务:

cs 复制代码
// 替换默认的 Factory
builder.Services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();
// 注意:不再需要 AddLocalization,因为我们要完全接管 Localizer 的创建

基于 Redis 存储的本地化

使用 StackExchange.Redis 将翻译存储在 Redis 中。适合需要动态更新翻译且无需重启应用的场景。

安装 NuGet包:

StackExchange.Redis

实现 Redis Localizer:

cs 复制代码
using Microsoft.Extensions.Localization;
using StackExchange.Redis;
using System.Collections.Concurrent;

public class RedisStringLocalizer : IStringLocalizer
{
    private readonly IDatabase _db;
    private readonly string _culture;
    // 简单内存缓存,避免频繁请求 Redis
    private static readonly ConcurrentDictionary<string, string> _cache = new();

    public RedisStringLocalizer(IConnectionMultiplexer redis, string culture)
    {
        _db = redis.GetDatabase();
        _culture = culture;
    }

    public LocalizedString this[string name]
    {
        get
        {
            var cacheKey = $"{_culture}:{name}";
            
            // 检查内存缓存
            if (_cache.TryGetValue(cacheKey, out var cachedValue))
            {
                return new LocalizedString(name, cachedValue, false);
            }

            // Redis Key 格式: lang:{culture}:{key}
            var redisKey = $"lang:{_culture}:{name}";
            var value = _db.StringGet(redisKey);

            if (value.HasValue)
            {
                var strValue = value.ToString();
                _cache.TryAdd(cacheKey, strValue);
                return new LocalizedString(name, strValue, false);
            }

            return new LocalizedString(name, name, true);
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var format = this[name].Value;
            return new LocalizedString(name, string.Format(format, arguments), false);
        }
    }

    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        // Redis 扫描所有键性能较差,生产环境建议实现特定索引或禁用此方法
        return Enumerable.Empty<LocalizedString>();
    }
}

public class RedisStringLocalizerFactory : IStringLocalizerFactory
{
    private readonly IConnectionMultiplexer _redis;

    public RedisStringLocalizerFactory(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    public IStringLocalizer Create(Type resourceSource)
    {
        return new RedisStringLocalizer(_redis, CultureInfo.CurrentUICulture.Name);
    }

    public IStringLocalizer Create(string baseName, string location)
    {
        return new RedisStringLocalizer(_redis, CultureInfo.CurrentUICulture.Name);
    }
}

注册服务:

cs 复制代码
// 1. 注册 Redis 连接
builder.Services.AddSingleton<IConnectionMultiplexer>(sp => 
{
    return ConnectionMultiplexer.Connect("localhost:6379");
});

// 2. 替换 Localizer Factory
builder.Services.AddSingleton<IStringLocalizerFactory, RedisStringLocalizerFactory>();

测试数据写入 (Redis CLI):

SET lang:zh-CN:Hello "你好,Redis!"

SET lang:en-US:Hello "Hello from Redis!"

相关推荐
1-1=03 小时前
.NET Core 健康检查
.net·.netcore·1024程序员节
淡海水4 小时前
07-03-源码-.NET Core GC标记与扫描
unity·c#·.netcore·gc
波力海苔夹心脆6755 小时前
C# string 字符串方法大全:判空、查找、截取、分割、替换一篇讲透
经验分享·c#·.net·字符串·string·入门教程
csdn_aspnet5 小时前
.NETCore SqlSuagr 根据 mysql 数据表按顺序生成简单编号
mysql·.netcore·sqlsugar·1024程序员节·sequence·.net8·编号
小羊没烦恼!5 小时前
.NET无侵入自动化探针原理和主流实现
运维·开发语言·javascript·python·自动化·.net·1024程序员节
IT规划师5 小时前
MQTT实战 - 遗嘱消息+心跳机制,让你的设备永远不会“假在线”(七)
物联网·.net
淡海水1 天前
07-01-源码-.NET Core GC总览与架构
unity·架构·c#·.netcore·gc
csdn_aspnet1 天前
在 .NET Core 中使用 Redis
redis·.netcore
公子小六1 天前
基于.NET的Windows窗体编程之WinForms自定义控件(3)
windows·c#·.net·音视频·winforms