事实证明ValidationAttributeAdapterProvider 方法不起作用,因为它仅用于“客户端验证属性”(这对我来说没有多大意义,因为属性是在服务器模型上指定的) .
但我找到了一个解决方案,可以用自定义消息覆盖所有属性。它还能够注入字段名称翻译,而不会到处乱吐[Display]。这是实际操作中的约定优于配置。
此外,作为奖励,此解决方案会覆盖甚至在验证发生之前使用的默认模型绑定错误文本。一个警告 - 如果您收到 JSON 数据,那么 Json.Net 错误将被合并到 ModelState 错误中,并且不会使用默认绑定错误。我还没有想出如何防止这种情况发生。
因此,您需要以下三个课程:
public class LocalizableValidationMetadataProvider : IValidationMetadataProvider
{
private IStringLocalizer _stringLocalizer;
private Type _injectableType;
public LocalizableValidationMetadataProvider(IStringLocalizer stringLocalizer, Type injectableType)
{
_stringLocalizer = stringLocalizer;
_injectableType = injectableType;
}
public void CreateValidationMetadata(ValidationMetadataProviderContext context)
{
// ignore non-properties and types that do not match some model base type
if (context.Key.ContainerType == null ||
!_injectableType.IsAssignableFrom(context.Key.ContainerType))
return;
// In the code below I assume that expected use of ErrorMessage will be:
// 1 - not set when it is ok to fill with the default translation from the resource file
// 2 - set to a specific key in the resources file to override my defaults
// 3 - never set to a final text value
var propertyName = context.Key.Name;
var modelName = context.Key.ContainerType.Name;
// sanity check
if (string.IsNullOrEmpty(propertyName) || string.IsNullOrEmpty(modelName))
return;
foreach (var attribute in context.ValidationMetadata.ValidatorMetadata)
{
var tAttr = attribute as ValidationAttribute;
if (tAttr != null)
{
// at first, assume the text to be generic error
var errorName = tAttr.GetType().Name;
var fallbackName = errorName + "_ValidationError";
// Will look for generic widely known resource keys like
// MaxLengthAttribute_ValidationError
// RangeAttribute_ValidationError
// EmailAddressAttribute_ValidationError
// RequiredAttribute_ValidationError
// etc.
// Treat errormessage as resource name, if it's set,
// otherwise assume default.
var name = tAttr.ErrorMessage ?? fallbackName;
// At first, attempt to retrieve model specific text
var localized = _stringLocalizer[name];
// Some attributes come with texts already preset (breaking the rule 3),
// even if we didn't do that explicitly on the attribute.
// For example [EmailAddress] has entire message already filled in by MVC.
// Therefore we first check if we could find the value by the given key;
// if not, then fall back to default name.
// Final attempt - default name from property alone
if (localized.ResourceNotFound) // missing key or prefilled text
localized = _stringLocalizer[fallbackName];
// If not found yet, then give up, leave initially determined name as it is
var text = localized.ResourceNotFound ? name : localized;
tAttr.ErrorMessage = text;
}
}
}
}
public class LocalizableInjectingDisplayNameProvider : IDisplayMetadataProvider
{
private IStringLocalizer _stringLocalizer;
private Type _injectableType;
public LocalizableInjectingDisplayNameProvider(IStringLocalizer stringLocalizer, Type injectableType)
{
_stringLocalizer = stringLocalizer;
_injectableType = injectableType;
}
public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
{
// ignore non-properties and types that do not match some model base type
if (context.Key.ContainerType == null ||
!_injectableType.IsAssignableFrom(context.Key.ContainerType))
return;
// In the code below I assume that expected use of field name will be:
// 1 - [Display] or Name not set when it is ok to fill with the default translation from the resource file
// 2 - [Display(Name = x)]set to a specific key in the resources file to override my defaults
var propertyName = context.Key.Name;
var modelName = context.Key.ContainerType.Name;
// sanity check
if (string.IsNullOrEmpty(propertyName) || string.IsNullOrEmpty(modelName))
return;
var fallbackName = propertyName + "_FieldName";
// If explicit name is missing, will try to fall back to generic widely known field name,
// which should exist in resources (such as "Name_FieldName", "Id_FieldName", "Version_FieldName", "DateCreated_FieldName" ...)
var name = fallbackName;
// If Display attribute was given, use the last of it
// to extract the name to use as resource key
foreach (var attribute in context.PropertyAttributes)
{
var tAttr = attribute as DisplayAttribute;
if (tAttr != null)
{
// Treat Display.Name as resource name, if it's set,
// otherwise assume default.
name = tAttr.Name ?? fallbackName;
}
}
// At first, attempt to retrieve model specific text
var localized = _stringLocalizer[name];
// Final attempt - default name from property alone
if (localized.ResourceNotFound)
localized = _stringLocalizer[fallbackName];
// If not found yet, then give up, leave initially determined name as it is
var text = localized.ResourceNotFound ? name : localized;
context.DisplayMetadata.DisplayName = () => text;
}
}
public static class LocalizedModelBindingMessageExtensions
{
public static IMvcBuilder AddModelBindingMessagesLocalizer(this IMvcBuilder mvc,
IServiceCollection services, Type modelBaseType)
{
var factory = services.BuildServiceProvider().GetService<IStringLocalizerFactory>();
var VL = factory.Create(typeof(ValidationMessagesResource));
var DL = factory.Create(typeof(FieldNamesResource));
return mvc.AddMvcOptions(o =>
{
// for validation error messages
o.ModelMetadataDetailsProviders.Add(new LocalizableValidationMetadataProvider(VL, modelBaseType));
// for field names
o.ModelMetadataDetailsProviders.Add(new LocalizableInjectingDisplayNameProvider(DL, modelBaseType));
// does not work for JSON models - Json.Net throws its own error messages into ModelState :(
// ModelBindingMessageProvider is only for FromForm
// Json works for FromBody and needs a separate format interceptor
DefaultModelBindingMessageProvider provider = o.ModelBindingMessageProvider;
provider.SetValueIsInvalidAccessor((v) => VL["FormatHtmlGeneration_ValueIsInvalid", v]);
provider.SetAttemptedValueIsInvalidAccessor((v, x) => VL["FormatModelState_AttemptedValueIsInvalid", v, x]);
provider.SetMissingBindRequiredValueAccessor((v) => VL["FormatModelBinding_MissingBindRequiredMember", v]);
provider.SetMissingKeyOrValueAccessor(() => VL["FormatKeyValuePair_BothKeyAndValueMustBePresent" ]);
provider.SetMissingRequestBodyRequiredValueAccessor(() => VL["FormatModelBinding_MissingRequestBodyRequiredMember"]);
provider.SetNonPropertyAttemptedValueIsInvalidAccessor((v) => VL["FormatModelState_NonPropertyAttemptedValueIsInvalid", v]);
provider.SetNonPropertyUnknownValueIsInvalidAccessor(() => VL["FormatModelState_UnknownValueIsInvalid"]);
provider.SetUnknownValueIsInvalidAccessor((v) => VL["FormatModelState_NonPropertyUnknownValueIsInvalid", v]);
provider.SetValueMustNotBeNullAccessor((v) => VL["FormatModelBinding_NullValueNotValid", v]);
provider.SetValueMustBeANumberAccessor((v) => VL["FormatHtmlGeneration_ValueMustBeNumber", v]);
provider.SetNonPropertyValueMustBeANumberAccessor(() => VL["FormatHtmlGeneration_NonPropertyValueMustBeNumber"]);
});
}
}
在 Startup.cs 文件的 ConfigureServices 中:
services.AddMvc( ... )
.AddModelBindingMessagesLocalizer(services, typeof(IDtoModel));
我在这里使用了我的自定义空IDtoModel 接口并将其应用于我所有需要自动本地化错误和字段名称的 API 模型。
创建一个文件夹 Resources 并将空类 ValidationMessagesResource 和 FieldNamesResource 放入其中。
创建 ValidationMessagesResource.ab-CD.resx 和 FieldNamesResource .ab-CD.resx 文件(将 ab-CD 替换为所需的文化)。
填写您需要的键的值,例如FormatModelBinding_MissingBindRequiredMember, MaxLengthAttribute_ValidationError ...
从浏览器启动 API 时,确保将 accept-languages 标头修改为您的文化名称,否则 Core 将使用它而不是默认值。对于只需要单一语言的 API,我更喜欢使用以下代码完全禁用文化提供者:
private readonly CultureInfo[] _supportedCultures = new[] {
new CultureInfo("ab-CD")
};
...
var ci = new CultureInfo("ab-CD");
// can customize decimal separator to match your needs - some customers require to go against culture defaults and, for example, use . instead of , as decimal separator or use different date format
/*
ci.NumberFormat.NumberDecimalSeparator = ".";
ci.NumberFormat.CurrencyDecimalSeparator = ".";
*/
_defaultRequestCulture = new RequestCulture(ci, ci);
...
services.Configure<RequestLocalizationOptions>(options =>
{
options.DefaultRequestCulture = _defaultRequestCulture;
options.SupportedCultures = _supportedCultures;
options.SupportedUICultures = _supportedCultures;
options.RequestCultureProviders = new List<IRequestCultureProvider>(); // empty list - use default value always
});