ASP.NET MVC I18n – Teil 7: Model Attribute

10. Februar 2014

Der letzte Teil hat Ressourcen eingeführt und direkt per Code verwendet. Wenn man View-Anteile lokalisieren will, die auf Daten basieren – als Databinding gegen die Models – kommen ebenfalls Ressourcen ins Spiel.

Hinweis: Dieser Beitrag ist Teil einer Serie, die Übersicht findet sich hier.

 

Lokalisierung der Daten-bezogenen Informationen ist ein recht gut dokumentierter Aspekt, daher kann ich die Grundlagen im Schnelldurchgang abhandeln und mich auf die Sonderfälle konzentrieren. Wer eine Einführung braucht, findet diese auf der ASP.NET site.

Hinweis: Dieser Beitrag wird sich um Labels und Validierungsfehlermeldungen kümmern; Validierung selbst kommt im nächsten Beitrag.

 

Das M in MVC steht für das Model, in ASP.NET MVC typischerweise POCOs. Validierung und Lokalisierung basiert auf Attributen auf den Properties der Models. Die erste Frage – die in Tutorials in der Regel nicht gestellt wird – ist eher eine Architekturfrage: Welche Klasse steht für unser Model?

Im initialen Beitrag hatte ich den Business Contract eingeführt, der folgende Datenklasse enthielt:

   1: namespace MyStocks.BusinessContract 

   2: { 

   3:     [DebuggerDisplay("BusinessContract.Stock: {ID} {Isin} {Name} {Price} {Date}")] 

   4:     public class Stock 

   5:     { 

   6:         public int ID { get; set; } 

   7:         /// <summary> 

   8:         /// http://en.wikipedia.org/wiki/International_Securities_Identification_Number 

   9:         /// 12-character alpha-numerical code 

  10:         /// </summary> 

  11:         public string Isin { get; set; } 

  12:         public string Name { get; set; } 

  13:         public decimal Price { get; set; } 

  14:         public DateTime Date { get; set; } 

  15:     } 

  16: } 

Rein technisch betrachtet könnten wir diese natürlich um die notwendigen Attribute ergänzen, allerdings würde der Contract dann durch UI-Informationen “verunreinigt”. Um es kurz zu machen: Das ist keine gute Idee (das Thema habe ich bereits behandelt).

Stattdessen werden eigene Model-Klassen im Presentation Layer hinterlegt. (Für dieses einfache Beispiel reicht eine Kopie des Data Contracts, nach meiner Erfahrung haben Models aber die Tendenz sich stärker an den Views auszurichten – um den Begriff ViewModel zu vermeiden.)

Darauf kann man dann die Validierungs-Attribute packen:

   1: [DebuggerDisplay("Models.Stock: {ID} {Isin} {Name} {Price} {Date}")]

   2: public class Stock

   3: {

   4:     public int ID { get; set; }

   5:  

   6:     [Required]

   7:     [RegularExpression(@"[a-zA-Z]{2}[w]{9}[d]{1}")]

   8:     public string Isin { get; set; }

   9:  

  10:     [Required]

  11:     public string Name { get; set; }

  12:  

  13:     [Required]

  14:     [DisplayFormat(DataFormatString = "{0:f2}")] // currency would include currency sign

  15:     public decimal Price { get; set; }

  16:  

  17:     [Required]

  18:     [DataType(DataType.Date)]

  19:     public DateTime Date { get; set; }

  20: }

Natürlich wird jetzt ein Datenmapping benötigt. In komplexeren Szenarien würde man hier einen Automatismus, etwa AutoMapper, in Betracht ziehen, in unserem Beispiel ist Handarbeit ausreichend:

   1: using ContractStock = MyStocks.BusinessContract.Stock; // business contract model 

   2: using ModelStock = MyStocks.Mvc.Models.Stock; // mvc model 

   3:  

   4: namespace MyStocks.Mvc.Models 

   5: { 

   6:     static class Mapper 

   7:     { 

   8:         public static ModelStock MapToUI(this ContractStock stock) 

   9:         { 

  10:             return new ModelStock 

  11:             { 

  12:                 Date = stock.Date, 

  13:                 ID = stock.ID, 

  14:                 Isin = stock.Isin, 

  15:                 Name = stock.Name, 

  16:                 Price = stock.Price 

  17:             }; 

  18:         } 

  19:          

  20:         public static ContractStock MapToContract(this ModelStock stock) 

  21:         { 

  22:             return new ContractStock { ... }; 

  23:         } 

  24:     } 

  25: } 

Dazu die entsprechenden Aufrufe im Controller:

   1: public class StockController : Controller 

   2: { 

   3:     protected MyStocks.BusinessContract.IStockService StockService { get; set; } 

   4:      

   5:     public StockController() 

   6:     { 

   7:         this.StockService = new MyStocks.BusinessService.StockService(); 

   8:     } 

   9:      

  10:     // 

  11:     // GET: /Stock/ 

  12:     public ActionResult Index() 

  13:     { 

  14:         var model = StockService.GetAllStocks().Select(Mapper.MapToUI); 

  15:         return View(model); 

  16:     } 

Die größtenteils generierten typisierten Views greifen das Model über Extension-Methods ab:

   1: @model IEnumerable<MyStocks.Mvc.Models.Stock> 

   2: @{ 

   3:      ViewBag.Title = Labels.Navigation_Stock_Index; 

   4: } 

   5: <h2>@ViewBag.Title</h2> 

   6: <table> 

   7:     <tr> 

   8:         <th>@Html.DisplayNameFor(model => model.Isin)</th> 

   9:         <th>@Html.DisplayNameFor(model => model.Name)</th> 

  10:         <th>@Html.DisplayNameFor(model => model.Price)</th> 

  11:         <th>@Html.DisplayNameFor(model => model.Date)</th> 

  12:     </tr> 

  13:     @foreach (var item in Model) 

  14:     { 

  15:         <tr> 

  16:             <td>@Html.ActionLink(item.Isin, "Details", new { id = item.ID })</td> 

  17:             <td>@Html.DisplayFor(modelItem => item.Name)</td> 

  18:             <td style="text-align:right">@Html.DisplayFor(modelItem => item.Price) €</td> 

  19:             <td>@Html.DisplayFor(modelItem => item.Date)</td> 

  20:         </tr> 

  21:     } 

  22: </table> 

  23: <br /> 

  24: <br /> 

  25: @Html.ActionLink(Labels.Navigation_Back, "Index", "Home")

Lässt man die Anwendung laufen ist das Ergebnis folgendermaßen:

Lokalisierung-7-app1

Die Namen der Properties werden als Bezeichner in der Ausgabe verwendet. Springt man in die Details und die Edit-View, werden auch Validierungsfehler gemeldet:

Lokalisierung-7-app2

Es ist sogar möglich, dass die Meldungen bereits übersetzt sind. Das hängt allerdings von der Sprachversion des installierten .NET Framework ab. Generell würde ich mich ohnehin nicht darauf verlassen wollen, dass in Produktion alle benötigten Sprachen vorhanden sind. Spätestens wenn spezifische Meldungen benötigt werden muss man ohnehin Hand anlegen.

Lokalisierte Validierungsmeldungen werden über entsprechende Verweisen auf Ressourcen in den Validierungs-Attributen angegeben. Display kommt für die Labels dazu:

   1: [DebuggerDisplay("Models.Stock: {ID} {Isin} {Name} {Price} {Date}")] 

   2: public class Stock 

   3: { 

   4:     public int ID { get; set; } 

   5:      

   6:     [Required(ErrorMessageResourceType = typeof(Localizations.Models), ErrorMessageResourceName = "Validation_Required")] 

   7:     [RegularExpression(@"[a-zA-Z]{2}[w]{9}[d]{1}", ErrorMessageResourceType = typeof(Localizations.Models), ErrorMessageResourceName = "Stock_Isin_RegEx")] 

   8:     [Display(ResourceType = typeof(Localizations.Models), Name = "Stock_Isin")] 

   9:     public string Isin { get; set; } 

  10:  

  11:     [...]

  12: } 

 

Soweit findet man die Informationen durchaus in den üblichen Tutorials. Was hingegen selten erwähnt wird (auch in der Dokumentation nicht) ist die Möglichkeit, string.Format-Platzhalter zu verwenden. Es ist nicht notwendig, für jedes Feld einen eigenen “Feld XY ist ein Pflichtfeld!”-Text zu hinterlegen (wie in manchen Beiträgen dargestellt…).

Alle Validierungs-Attribute unterstützen zumindest Platzhalter für den Feldnamen, bei vielen kommen die eigenen Properties hinzu, etwa die Länge beim MinLengthAttribute. Folgende Texte in den Ressourcen sind also ausreichend für die Standardfälle:

Validation_Required= „{0} is a required field!“

Validation_MaxLength= „{0} must be shorter or equal to {1} characters!“

Nebenbei: Wer ein Problem mit der inflationären Verwendung der Attribute hat, findet bei Phil einen Ansatz um die Ressourcen über einen auf Konventionen basierenden Mechanismus automatisch zu verwenden.

 

Ein Loch müssen wir noch stopfen…

Die meisten Validierungsfehler werden ordentlich lokalisiert, mit einer Ausnahme:

Lokalisierung-7-app3

Die Meldung beim Datum kommt nicht von der Validierung, sondern vom Binding. Binding ist der Mechanismus, der den eingegeben Text in den Datentyp umwandelt (was “syntaktische Korrektheit” impliziert), während die eigentliche Validierung in ASP.NET MVC sich auf inhaltliche (“semantische”) Validierung bezieht. (Hintergründe dazu finden sich hier.)

Für das Binding funktioniert aber auch die Lokalisierung anders. Tatsächlich gibt es nur zwei Fälle, numerische Werte und Datumswerte, und nur einen Weg, die Übersetzungen bereitzustellen:

  1. Man platziert eine Ressource-Datei in App_GlobalResources – Ja, ich sagte der Ordner ist tabu. Das hier ist die Ausnahme die die Regel bestätigt.

  2. Man definiert zwei Meldungen mit exakt diesen Namen:

    Lokalisierung-7-resources 

    Dazu kommen natürlich die entsprechend lokalisierten Varianten.

  3. Man teilt dem Model Binder mit, dass er diese Ressource verwenden soll (Name der Ressource, d.h. der Dateiname ohne .resx):
       1: public static void InitializeLocalization() 

       2: { 

       3:     ClientDataTypeModelValidatorProvider.ResourceClassKey = "GlobalResources"; 

       4: } 

Nebenbei: Falls jemand über DefaultModelBinder.ResourceClassKey stolpert, das ist offensichtlich veraltet…

 

Und jetzt tut es lückenlos:

Lokalisierung-7-app4

Falls sich jemand fragt, warum dieser Sonderfall so verquer gelöst ist…

“Aside from the standard localization of MVC, this message isn’t easily changeable (it was added at the last minute, and we didn’t offer an override unfortunately).” Im Forum, Kommentar von Brad Wils, Betonung von mir.

LOL!

 

Der Vollständigkeit halber: Alle diese Aussagen basieren darauf, dass MVC jQuery unobtrusive validation verwendet. Wer jQuery validation direkt verwenden will kann das lokalisieren indem er die Meldungen hinter $.validator.messages übersetzt. Hier gibt es ein Beispiel.