ch09 全球化与本地化
针对不同的环境与支持, 需要让应用支持多语言, 就需要使用全球化与本地化.
就个人开发环境来说, 这里比较麻烦的是时间. 涉及到时区等细节.
本章涉及到的内容包括:
- 全球化与本地化简介
- 对 minimal API 应用进行本地化
- 使用资源文件
- 在验证框架中集成本地化
- 在国际化中添加 UTC 支持
前期准备 (略)
安装 dotnet 6, 创建 ASP.NET Core 6.0 Web API 应用.
全球与本地化简介
全球化与本地化的区别:
- 全球化是设计任务, 让应用程序可以支持与管理不同的 culture.
- 本地化是执行过程, 让应用程序适配某个特定的 culture.
注意:
国际化, 全球化, 本地化等术语, 分别是 I18N, G11N, 以及 L10N 的简称.
minimal API 和基于 控制器 的 API, 全球化与本地化均是采用中间件的形式进行支持.
细节可以参考官方文档:
后续内容重点放在, 如何在 minimal API 中支持该特性.
本地化 minimal API 应用程序
按照下面步骤处理:
创建
CultureInfo
数组, 来初始化需要支持的语言.var supportedCultures = new CultureInfo[] { new ("en"), new ("it"), new ("fr"), }; builder.Services.Configure<RequestLocalizationOptions>(opts => { opts.SupportedCultures = supportedCultures; opts.SupportedUICultures = supportedCultures; opts.DefaultRequestCulture = new RequestCulture(supportedCultures.First()); });
这段代码支持 英语, 意大利语, 以及法语.
代码中仅创建的语言, 而没有涉及到具体国家地区. 例如
en
有en-US
和en-UK
的区别. 需要注意的是, 涉及到国家与地区的有很大的不同, 例如在时间格式, 某些单词的拼写等都存在一定差异. 但是在不需要特定说明的情况下, 使用中性的语言就够了.下一步, 配置
RequestLocalizationOptions
. 设置 culture, 以及在缺省情况下的默认值. 这里同时设置了 culture 和 UI culture.- 支持 culture 是用于控制依赖于 culture 的函数输出用的, 例如日期格式, 时间格式, 数字格式等.
- UI culture 用于选择对字符串的转换 (从
.resx
文件读取资源), 后面会介绍.resx
文件.
现在全球化已经配置好, 然后需要在管道中间件中使用本地化, 来启用对应功能.
app.UseRequestLocalization();
该代码将
RequestLocalizationMiddleware
中间件添加到 ASP.NET Core 管道中. 来为每一个请求设置 culture.该任务基于
RequestCultureProvider
列表来执行, 它允许从不同资源中读取 culture 信息. 默认的 provider 如下:QueryStringRequestCultureProvider
从查询字符串中读取 culture 和 ui-cultureCookieRequestCultureProvider
使用 ASP.NET Core cookieAcceptLanguageHeaderRequestCultureProvider
从请求头 Accept-Language 中读取需要的 culture
针对每一个请求, 会严格按照这个顺序来读取, 并找到后返回. 如果没有 culture, 则会使用
DefaultRequestCulture
中配置的.如果需要调整这个顺序, 或使用自定义的 provider 可以参考文档.
重要: 本地化中间件应该要放在其他中间件的前面.
无论是在基于 控制的 API 还是 Minimal API 中, 使用 culture 一般都是在
Accept-Language
的 HTTP 请求头中进行设置.
在 Swagger 中支持全球化
在 Swagger 中提供配置, 在请求头中添加 Accept-Language
项. 技术上来说就是在 swagger 中添加一个选项过滤器 (option filter):
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
public class AcceptLanguageHeaderOperationFilter: IOperationFilter {
private readonly List<IOpenApiAny>? supportedLanguages;
public AcceptLanguageHeaderOperationFilter(IOptions<RequestLocalizationOptions> requestLocalizationOptions) {
supportedLanguages = requestLocalizationOptions.Value
.SupportedCultures?.Select(c => new OpenApiString(c.TwoLetterISOLanguageName))
.Cast<IOpenApiAny>()
.ToList();
}
public void Apply(OpenApiOperation operation, OperationFilterContext context) {
if (supportedLanguages?.Any() ?? false) {
operation.Parameters ??= new List<OpenApiParameter>();
operation.Parameters.Add(new OpenApiParameter {
Name = HeaderNames.AcceptLanguage,
In = ParameterLocation.Header,
Required = false,
Schema = new OpenApiSchema {
Type = "string",
Enum = supportedLanguages,
Default = supportedLanguages.First()
}
});
}
}
}
然后作者对代码进行了解释. 该部分的细节还是需要阅读 swagger 的文档.
添加该 Filter
之后, 会在每一个请求中调用该 Apply()
方法. 最后将其添加到 swagger 中
builder.Services.AddSwaggerGen(options => {
options.OperationFilter<AcceptLanguageHeaderOperationFilter>();
});
jk:
这段代码的含义是在 swagger 中为每一个请求添加一个默认的参数
app.MapGet("/culture", () => Thread.CurrentThread.CurrentCulture.DisplayName);
使用资源文件
至此 Swagger 已经支持了全球化, 也就是说可以在请求中根据不同的 culture 进行响应. 也就是说, 我们已经具备根据不同的 culture 的请求来响应不同的消息了, 例如错误的消息提示. 该特性是基于资源文件 (.resx
文件) 来实现的. 它是一个键值对的 XML 文件, 其中的键值对就是描述本地化的信息.
注意, 该文件与早期 .NET Fx 的完全相同.
创建并使用 资源文件
利用资源文件可以将字符串从代码中分离出来. 一般, 会将资源文件放在 Resources
文件夹中.
注意:
VSCode
不支持处理.resx
文件, 在 VS 中都单独的可视化界面来映射该文件与操作. 细节可以参考 issues
主要是因为不仅仅是 XML 文件, 在 VS 中, 编辑资源文件背后还会生成相应的与用于加载资源的类型与属性以供使用.
作者演示了在 VS 中的操作. 核心步骤:
- 添加资源文件 (自行创建
Resources
文件夹), 注意文件名:Messages.resx
,Messages.it.resx
. - 在编辑键值对时, 将访问修饰符更改为
public
在使用的时候直接使用 Messages.xxx
.
细节说明:
- 添加
resx
文件后会在后台生成同名, 后缀为Designer.cs
的文件. - 会自动生成一个类, 类名就是
resx
文件名. - 在
resx
中添加的键值对的键会作为属性名使用. - 不同语言的资源文件, 采用二级名描述, 例如 意大利使用
Messages.it.resx
然后添加端点:
app.MapGet("/hello", () => Messages.Message);
运行的细节:
- 对应语言资源文件不存在时 (这里是法语), 会使用默认 (即英语).
- 对应语言资源存在, 但是键不存在时, 会使用默认的对应键.
- 资源提供的数据仅为字符串, 可以使用
{0}
来站位, 使用string.Format
来对字符串进行格式化.
在校验框架中集成本地化
在 ch06 中介绍了 MiniValidation
和 FluentValidation
的使用. 下面介绍如何在其中集成本地化消息.
在 MiniValidation 中集成本地化
使用 MiniValidation
库, 基于 Data Annotation 来使用验证. 参考 ch06, 在项目中引入该库.
dotnet add package MiniValidation --version 0.9.0
然后再次创建 Person
类:
public class Person {
[Required]
[MaxLength(30)]
public string FirstName { get; set; }
[Required]
[MaxLength(30)]
public string LastName { get; set; }
[EmailAddress]
[StringLength(100, MinimumLength = 6)]
public string Email { get; set; }
}
每一个验证特性都可以有一个消息提示, 在验证失败时显示. 这个消息可以是一个静态字符串, 也可以来自资源文件.
在资源文件中添加下面的内容:
Name | Messages.resx 中的 Value | Messages.it.resx 中的 Value |
---|---|---|
FieldRequiredAnotation | The field `{0}' is required | Il campo `{0}' è obbligatprio |
FirstName | First Name | Nome |
LastName | Last Name | Cognome |
ValidationErrors | One or more validation errors occurred. | Si sono verificati errori di validazione. |
考虑到必填字段使用的消息中存在占位符, 因此需要对属性名进行转换:
using localizingMinimalApi.Resources;
...
public class Person {
[Display(Name = "FirstName", ResourceType = typeof(Messages))]
[Required(ErrorMessageResourceName = "FieldRequiredAnnotation", ErrorMessageResourceType = typeof(Messages))]
public string? FirstName { get; set; }
...
}
每一个 Required
的验证属性都用这个方法来实现. 指定资源文件中对应的的类型与名字.
但是需要记住, 字符串是静态数据, 不会进行校验, 如果拼写错误是不会有提示的.
然后依次为其他属性添加 Display
等注解. 可以参考源代码.
然后如同 ch06 中一样, 添加验证代码, 只不过这次的消息会本地化.
app.MapPost("/people", (Person person) => {
var isValid = MiniValidator.TryValidate(person, out var errors);
if (!isValid) {
return Results.ValidationProblem(errors, title: Messages.ValidationErrors);
}
return Results.NoContent();
});
然后这里改写了作者的案例, 使用 en 和 zh 两个语言.
运行结果:
在 FluentValidation 中集成本地化
使用 FluentValidation
可以将验证规则与模型完全解耦.
安装包:
dotnet add package FluentValidation.DependencyInjectionExtensions
添加中间件:
// Program.cs 文件中
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
下面创建 PersonValidator
类:
using FluentValidation;
public class PersonValidator: AbstractValidator<Person> {
public PersonValidator() {
RuleFor(p => p.FirstName).NotEmpty().MaximumLength(30);
}
}
然后添加资源文件数据 (以 en 和 zh 为例)
Name | Messages.resx 中的 Value | Messages.zh.resx 中的 Value |
---|---|---|
NotEmptyMessage | The field '{PropertyName}' is Required | 必须提供属性 '{PropertyName}' |
注意, 与 数据注解不同的是, 这里使用的是具名占位符. 然后修改代码:
using FluentValidation;
using localizingMinimalApi.Resources;
public class PersonValidator: AbstractValidator<Person> {
public PersonValidator() {
RuleFor(p => p.FirstName).NotEmpty()
.WithMessage(Messages.NotEmptyMessage)
.WithName(Messages.FirstName)
.MaximumLength(30);
}
}
完整代码可以参考 Github 上的仓库.
最后编写端点代码:
app.MapPost("/people2", async (Person person, IValidator<Person> validator) => {
var validationResult = await validator.ValidateAsync(person);
if (!validationResult.IsValid) {
var errors = validationResult.ToDictionary();
return Results.ValidationProblem(errors, title: Messages.ValidationErrors);
}
return Results.NoContent();
});
案例中对每一个属性进行了消息处理, FluentValidation
中可以对所有消息进行替换, 细节可以参考官方文档
在全球化的 minimal API 中添加 UTC 支持
使用国际化的目的是为了让应用的受众变得更广. 有些内容可以不考虑具体的地区 (例如语言, 可以使用通用语), 但是例如时区等信息就必须考虑地区了.
在这个情况下, 可以考虑使用 DateTimeOffset
, 它是一个包含时区 (TimeZone) 的数据类型, 并且 JsonSerializer
完全支持它. 如果可以一直使用该数据类型, 那么全球化和本地化将变得很容易, 因为该类型提供了转换方法. 但有些情况下不支持该数据类型:
- 在早期系统中, 并重度依赖
DateTime
的数据类型时, 使用该数据类型将产生大量的断崖式修改. 会对数据造成一定破坏. - 某些数据库中, 例如 MySQL, 不支持该数据类型. 需要使用两个字段联合存储该数据类型来实现该功能.
- 有些情况下不关心时间, 只需要一个通用的时间格式.
jk: 看起来这个数据类型变化还是很大的.
因此并非所有情况下都需要使用 DateTimeOffset
类型. 处理时间时区最简单的处理办法是使用 Coordinated Universal Time (UTC) 来处理所有时间. 但必须保证时间数据存储是 UTC 格式的, 并且所有 API 以 UTC 格式进行返回.
这个处理过程应该集中处理, 而不是在每次在接收请求, 发送响应时进行转换. 非常流行的 JSON.NET
库提供了一个选项, 描述了在遇到 DateTime
属性时应该如何处理, 允许它对所有的时间作为 UTC 来处理, 并将其转换为本地时间. 但是, 当前 Minimal API 中所使用的 JsonSerializor
版本不支持. 并且在 ch02 中我们了解到, 在 MinimalAPI 中无法更换默认的 JSON 序列化库.
针对 dotnet 6 的版本, 不知道现在 dotnet 8 是否改进.
为了客服这个困难, 创建一个 简单的 JsonConverter
类:
using System.Text.Json;
using System.Text.Json.Serialization;
public class UTCDateTimeConverter : JsonConverter<DateTime> {
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetDateTime().ToUniversalTime();
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
=> writer.WriteStringValue(
(value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : value)
.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff'Z'")
);
}
该类的作用是告诉 JsonSerializer
如何处理 DateTime
数据类型:
- 当从
JSON
中读取DateTime
的时候, 使用ToUniversalTime()
方法将时间转换为UTC
格式. - 如何需要将
DateTIme
写入JSON
, 如果其表示为本地时间 (local time,DateTimeKind.Local
), 在序列化之前先转换为 UTC 时间. 序列化时的后缀'Z'
, 表示使用 UTC 格式.
下面在使用之前, 先编写端点方法
app.MapPost("/date", (DateInput date) => {
return Results.Ok(new {
Input = date.Value,
DateKind = date.Value.Kind.ToString(),
ServerDate = DateTime.Now
});
});
public record DateInput(DateTime Value);
然后运行项目, 使用 2022-03-06T16:42:37-05:00
来请求数据, 查看返回
输入的数据中是带有时区信息的. 在服务端会自动转换为本地时间 (书中地区为意大利, 上面截图在中国). 其他两个属性用于对比.
下面将 UtcDateTimeConverter
添加到 JsonSerializer
中:
using Microsoft.AspNetCore.Http.Json;
...
builder.Services.Configure<JsonOptions>(opts => {
opts.SerializerOptions.Converters.Add(new UTCDateTimeConverter());
});
这里注意命名空间.
至此每一个时间都会被自定义转换, 再次运行程序:
然后作者简单说明了一下代码的执行.
最后,为了能正确的使用 UTC, 还有一步需要注意:
- 代码中需要获得当前时间时, 使用
DateTime.UtcNow
来代替DateTime.Now
. - 客户端需要知道获得的时间是 UTC 格式的, 需要按照特定规则转换为本地时间.
至此 minimal API 才是真正完成全球化和本地化配置了.