ASP.NET Core(.NET 9)の表単位バリデーションでよく使う [Required] や [EmailAddress] の英語メッセージを、利用者のカルチャー(en‑US / fr‑FR など)ごとに“自前の日本語・フランス語メッセージ”へ完全に差し替える方法を、MVC と Razor Pages の観点で実装パターン別に整理しました。PRG での二重送信対策、OnPostAsync の定石、安易な文字列置換の落とし穴まで一気通貫で解説します。
結論(TL;DR)
アプリ全体の保守性と多言語対応のやりやすさを両立するには、次のいずれか(または併用)が定石です。
| 方法 | 概要 | 主なメリット | 主な注意点 |
|---|---|---|---|
| 公式推奨 : .resx リソースファイル方式 | 1) Resources 配下に言語別 .resx を配置し、ErrorMessageResourceType/ErrorMessageResourceName で参照。2) もしくは AddDataAnnotationsLocalization と DataAnnotationLocalizerProvider で、既定の DataAnnotations メッセージキー(例: RequiredAttribute_ValidationError)を自前リソースに向ける。 | Visual Studio だけで翻訳管理が完結。 キーと値の不整合をビルド時に検出。 大規模・長期運用に強い。 | .resx の Build Action を Embedded resource にすること。既定メッセージのプレースホルダー( {0} など)を正しく使うこと。 |
簡易版 : カスタム IStringLocalizer | 1) CustomStringLocalizer で Dictionary<string, Dictionary<string,string>> を持ち、キー×カルチャーで返す。2) DataAnnotationLocalizerProvider へ差し込む。 | 小規模ならコードのみで完結。ファイルを増やしたくない場合に手軽。 | 翻訳がソース埋め込みになり、量が増えると保守困難。翻訳ツール連携不可。 |
加えて、二重送信は PRG(Post‑Redirect‑Get)で「成功時は Redirect」に統一すると、エラー重複や連打問題を抑止できます(後述)。
前提の整理:どのメッセージを“どこで”変えるのか
ASP.NET Core の入力エラーメッセージは主に3層あります。どこを直すかを最初に決めると、設計がぶれません。
| 層 | 例 | 推奨アプローチ |
|---|---|---|
| DataAnnotations 層 | [Required] / [EmailAddress] / [StringLength] / [Range] など | .resx で明示指定 or AddDataAnnotationsLocalization で既定キーを差し替え |
| モデルバインド層 | 「’abc’ は数値ではありません」など型変換エラー | ModelBindingMessageProvider をローカライズ(後述の 全体差し替え) |
| ドメインルール層 | 「同一メールは登録済み」「営業日は平日のみ」など | IValidatableObject やサービス側の検証で独自メッセージを返す |
プロジェクト初期設定(.NET 9 / MVC & Razor Pages 共通)
Program.cs でローカライズとカルチャー判定を有効化します。Cookie > QueryString > Accept-Language の順に優先する例です。
using System.Globalization;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// 1) リソースの場所
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
// 2) MVC/Razor Pages + DataAnnotations ローカライズ
builder.Services
.AddControllersWithViews() // Razor Pages のみなら AddRazorPages()
.AddViewLocalization()
.AddDataAnnotationsLocalization(options =>
{
// DataAnnotations 既定メッセージを自前リソースに集約
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(ValidationMessages)); // Resources/ValidationMessages.*.resx
});
// 3) サポートするカルチャー
var supportedCultures = new[]
{
new CultureInfo("en-US"),
new CultureInfo("fr-FR"),
new CultureInfo("ja-JP")
};
builder.Services.Configure(options =>
{
options.DefaultRequestCulture = new RequestCulture("en-US");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
// 優先順位: Cookie, QueryString, Accept-Language
options.RequestCultureProviders.Insert(0, new CookieRequestCultureProvider());
options.RequestCultureProviders.Insert(1, new QueryStringRequestCultureProvider());
});
var app = builder.Build();
// 4) 毎リクエストでカルチャーを適用
app.UseRequestLocalization(app.Services
.GetRequiredService>().Value);
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
// MVC
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// Razor Pages
app.MapRazorPages();
app.Run();
カルチャー切り替えリンク(Cookie で保持)
ユーザーが明示的に言語を選べるよう、Cookie を設定するアクションを用意します。
// MVC コントローラ例
[HttpPost]
public IActionResult SetLanguage(string culture, string returnUrl)
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) });
return LocalRedirect(returnUrl);
}
<form asp-controller="Home" asp-action="SetLanguage" method="post">
<select name="culture">
<option value="en-US">English</option>
<option value="fr-FR">Français</option>
<option value="ja-JP">日本語</option>
</select>
<input type="hidden" name="returnUrl" value="@(Context.Request.Path + Context.Request.QueryString)" />
<button type="submit">Change</button>
</form>
方法A:各属性に .resx を明示指定(堅牢)
各プロパティに対し、ErrorMessageResourceType と ErrorMessageResourceName を指定する正攻法です。
プロパティ名の表示({0})も [Display] のリソースでローカライズできます。
public class UserViewModel
{
[Display(Name = "Label_Email", ResourceType = typeof(ValidationMessages))]
[Required(
ErrorMessageResourceType = typeof(ValidationMessages),
ErrorMessageResourceName = "Email_Required"
)]
[EmailAddress(
ErrorMessageResourceType = typeof(ValidationMessages),
ErrorMessageResourceName = "Email_Invalid"
)]
public string? Email { get; set; }
[Display(Name = "Label_Password", ResourceType = typeof(ValidationMessages))]
[Required(
ErrorMessageResourceType = typeof(ValidationMessages),
ErrorMessageResourceName = "Password_Required"
)]
[StringLength(100, MinimumLength = 8,
ErrorMessageResourceType = typeof(ValidationMessages),
ErrorMessageResourceName = "Password_Length")]
public string? Password { get; set; }
[Display(Name = "Label_ConfirmPassword", ResourceType = typeof(ValidationMessages))]
[Compare("Password",
ErrorMessageResourceType = typeof(ValidationMessages),
ErrorMessageResourceName = "Password_ConfirmMismatch")]
public string? ConfirmPassword { get; set; }
}
Resources/ValidationMessages.resx(既定)、ValidationMessages.fr.resx、ValidationMessages.ja.resx といったファイルを用意し、次のようなキーを登録します。
| キー | 値(例: ja-JP) | 備考 |
|---|---|---|
| Label_Email | メールアドレス | [Display] 用。{0} にはこの値が入る。 |
| Email_Required | {0} は必須です。 | {0} = Display 名 |
| Email_Invalid | {0} の形式が正しくありません。 | – |
| Password_Required | {0} を入力してください。 | – |
| Password_Length | {0} は {2}〜{1} 文字で入力してください。 | {1}=Max、{2}=Min |
| Password_ConfirmMismatch | {0} が一致しません。 | {0}=ConfirmPassword の表示名 |
ビュー(共通)
<form asp-action="Register" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
登録
方法B:既定メッセージをアプリ全体で差し替え(キー駆動)
「各属性に毎回 ErrorMessageResourceName を書くのは大変」という場合は、DataAnnotations の既定キーを丸ごと自前リソースへ向けると楽です。
先述の DataAnnotationLocalizerProvider 設定により、次のキーを ValidationMessages.*.resx に準備するだけで差し替わります。
| 属性 | キー名(代表例) | プレースホルダー | 例(ja-JP) |
|---|---|---|---|
| Required | RequiredAttribute_ValidationError | {0}=表示名 | {0} は必須です。 |
| EmailAddress | EmailAddressAttribute_Invalid | {0}=表示名 | {0} の形式が正しくありません。 |
| StringLength | StringLengthAttribute_ValidationError | {0}=表示名、{1}=Max、{2}=Min | {0} は {2}〜{1} 文字で入力してください。 |
| Range | RangeAttribute_ValidationError | {0}=表示名、{1}=最小、{2}=最大 | {0} は {1} 以上 {2} 以下で入力してください。 |
| Compare | CompareAttribute_MustMatch | {0}=表示名、{1}=比較先表示名 | {0} と {1} が一致しません。 |
| RegularExpression | RegularExpressionAttribute_ValidationError | {0}=表示名 | {0} の形式が正しくありません。 |
ポイントは、プロパティ側に [Display(Name="...", ResourceType=...)] を付けて表示名もローカライズすること。これにより {0} へ言語別のラベルが流れ、同じメッセージテンプレートでも自然な文章になります。
モデルバインド層のメッセージも忘れずに(型変換など)
「数値に文字列を入れた」など DataAnnotations 以外のエラーは ModelBindingMessageProvider で差し替えます。ローカライザを DI で受け取り、アプリ全体に適用する方法が定石です。
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
public sealed class MvcModelBindingMessagesSetup : IConfigureOptions
{
private readonly IStringLocalizer _L;
public MvcModelBindingMessagesSetup(IStringLocalizer localizer) => _L = localizer;
public void Configure(MvcOptions options)
{
options.ModelBindingMessageProvider.SetValueIsInvalidAccessor(_ => _L["ValueIsInvalid"]);
options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor((value, name) => _L["AttemptedValueIsInvalid", value, name]);
options.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(name => _L["MissingValue", name]);
options.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(_ => _L["ValueMustNotBeNull"]);
// 必要に応じて他のアクセサも
}
}
// Program.cs
builder.Services.AddTransient<IConfigureOptions<MvcOptions>, MvcModelBindingMessagesSetup>();
ValidationMessages.*.resx に ValueIsInvalid や MissingValue などのキーを追加しておきます。
クライアント側(jQuery Validation)の整合性
ASP.NET Core の unobtrusive validation は、サーバーで生成した data-val-* 属性にメッセージ文字列を埋め込み、jQuery Validation がそれをそのまま表示します。つまり、サーバー側のローカライズさえ正しければ、クライアント側も同じ文言になります。別途 jQuery の言語ファイルを上書きする必要は通常ありません。
Razor Pages の OnPostAsync と PRG(二重送信対策)
二重送信やエラー重複を避けるには、「NG は同じページ、OK はリダイレクト」を徹底します。
public class RegisterModel : PageModel
{
[BindProperty] public UserViewModel Input { get; set; } = new();
public void OnGet() { }
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
// バリデーション NG:同じページを再表示(メッセージはそのまま)
return Page();
}
// TODO: DB 登録など副作用
TempData["Flash"] = "登録が完了しました。";
// 成功時は PRG(リダイレクト)で二重送信と重複表示を回避
return RedirectToPage("Success");
}
}
クライアント側を補助するなら「送信ボタンを一時無効化」も有効ですが、最終的な二重防止はサーバー側(PRG)で担保してください。
<button type="submit" class="btn btn-primary" data-disable-on-click="true">送信</button>
<script>
document.addEventListener("click", function(e){
const b = e.target.closest("button[data-disable-on-click='true']");
if(!b) return;
if(b.dataset.clicked) { e.preventDefault(); return; }
b.dataset.clicked = "1";
b.disabled = true;
});
</script>
安易な「文字列置換」は避けるべき理由
アンチパターン: OnPost 後に ModelState のエラーメッセージ文字列を Replace で差し替えるやり方は、
- カルチャーが増えるほど条件分岐が爆発する
- フレームワークの文言変更(.NET 更新)で壊れる
- クライアント側の
data-valと不一致になる
結果として保守不能になります。必ずリソースまたはローカライザで「発生源」を置き換える方針にしましょう。
テストで守る:カルチャー別の回帰を防止
xUnit などで UICulture を切り替えて検証すると、翻訳の取りこぼしを自動検出できます。
[Fact]
public void Required_Message_Is_Localized_in_frFR()
{
var model = new UserViewModel { Email = "" }; // Required
var context = new ValidationContext(model);
var results = new List<ValidationResult>();
var prev = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = new CultureInfo("fr-FR");
Validator.TryValidateObject(model, context, results, validateAllProperties: true);
Assert.Contains(results, r => r.ErrorMessage!.Contains("obligatoire") /* 例 */);
}
finally
{
CultureInfo.CurrentUICulture = prev;
}
}
実際のアプリでは DI でローカライザを使うため、WebApplicationFactory を用いた統合テストでカルチャー付きリクエストを投げ、HTML 内のエラーメッセージをアサートするのが堅い手です。
よくあるハマりどころと対処
- リソースの Build Action が誤り:Embedded resource になっているか確認。
- キーのスペル違い:.NET 8 以降のローカライズ用ソースジェネレータを使うと、キーの型安全性が上がり、打ち間違いを検出できます。
- 表示名が英語のまま:
[Display(Name="...", ResourceType=...)]を忘れると{0}に英語が混ざります。 - カルチャーが切り替わらない:
UseRequestLocalization()を Routing より前に置く。Cookie の優先順位を確認。 - 複数回クリックでメッセージが増える:PRG に統一し、
ModelState.IsValidで NG のときは同一ページを返す。
応用:全属性をコードで上書き(カスタムアダプタ)
より踏み込んだ全体制御として、IValidationAttributeAdapterProvider をラップして、メッセージ生成を一括でローカライズする手もあります。DataAnnotations のテンプレートを強制的に自前ロジックへ通したいときに有効です。
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.Extensions.Localization;
public sealed class CustomAttributeAdapterProvider : IValidationAttributeAdapterProvider
{
private readonly IValidationAttributeAdapterProvider _base = new ValidationAttributeAdapterProvider();
private readonly IStringLocalizer _L;
public CustomAttributeAdapterProvider(IStringLocalizer<ValidationMessages> localizer)
=> _L = localizer;
public IAttributeAdapter? GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer stringLocalizer)
{
// 例:Required のメッセージを強制的に置き換える
if (attribute is RequiredAttribute req)
{
req.ErrorMessage = _L["RequiredAttribute_ValidationError"];
}
// 必要に応じて他の属性も…
return _base.GetAttributeAdapter(attribute, stringLocalizer);
}
}
// Program.cs
builder.Services.AddSingleton<IValidationAttributeAdapterProvider, CustomAttributeAdapterProvider>();
通常は B 方法(キー駆動の全体差し替え)で十分です。特殊要件があるときのみ検討してください。
Razor Pages / MVC の最小実装サンプル(ファイル構成)
MyApp/
├─ Program.cs
├─ Models/
│ └─ UserViewModel.cs
├─ Resources/
│ ├─ ValidationMessages.resx
│ ├─ ValidationMessages.ja.resx
│ └─ ValidationMessages.fr.resx
├─ Pages/ or Views/
│ └─ Account/
│ ├─ Register.cshtml
│ └─ Success.cshtml
└─ Controllers/(MVC の場合)
└─ HomeController.cs(SetLanguage など)
プレースホルダー早見表
| 属性 | 置換トークン | 意味 | 例 |
|---|---|---|---|
Required | {0} | 表示名 | {0} は必須です。 |
StringLength | {0}, {1}, {2} | 表示名, 最大, 最小 | {0} は {2}〜{1} 文字で入力してください。 |
Range | {0}, {1}, {2} | 表示名, 最小, 最大 | {0} は {1} 以上 {2} 以下で入力してください。 |
Compare | {0}, {1} | 表示名, 比較相手の表示名 | {0} と {1} が一致しません。 |
RegularExpression | {0} | 表示名 | {0} の形式が正しくありません。 |
小ネタ:.NET 8+
- ローカライズのソースジェネレータ:
IStringLocalizerのキーを定数化でき、未翻訳やタイプミスをビルド時に検出しやすくなります。長期運用の大規模案件では導入メリットが大きいです。 - Identity のエラーメッセージ:ログイン/登録の標準メッセージは
IdentityErrorDescriberを継承してローカライズ可能。フォームバリデーションと併せて統一感を出せます。
チェックリスト(最速で通すための要点)
builder.Services.AddLocalization(ResourcesPath="Resources")済みAddDataAnnotationsLocalization()を設定し、必要ならDataAnnotationLocalizerProviderで集約UseRequestLocalization()をミドルウェアの最上流に.resxの Build Action = Embedded resource[Display(..., ResourceType=...)]でラベルもローカライズ- モデルバインド層は
ModelBindingMessageProviderで置き換え - 成功時は PRG(
Redirect)で二重送信を根絶
まとめ
DataAnnotations のメッセージを“あとから文字列置換”するのではなく、リソース(.resx)とローカライザで発生源を置き換えるのが ASP.NET Core/.NET 9 の王道です。
A(属性ごとの明示指定)は堅牢、B(既定キー一括差し替え)は省力。さらにモデルバインド層もカバーし、PRG で二重送信を断つ。ここまで整えると、カルチャーが増えてもコストは線形に増えるだけで、将来のメンテナンスも安心です。

コメント