ASP.NET MVC I18n – Teil 2: Browser-Einstellungen auswerten

8. Januar 2014

Will man dem Anwender eine lokalisierte Anwendung präsentieren, dann sollte man zunächst wissen, welche Region er gerne hätte. Man könnte zwar einfach mit einer Standardregion beginnen und ihn wechseln lassen, aber er teilt uns ja mit, was er gerne hätte, also sollten wir uns danach richten.

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

 

Seine bevorzugte Sprache stellt der Anwender in den Browser-Settings ein…

Lokalisierung-3-BrowserSettings

Diese Information wird mit jedem Request in Form des accept-language http header mitgeschickt. Die Einträge oben führen etwa beim Internet Explorer zu folgendem Eintrag:

   1: Accept-Language: de-DE,de;q=0.8,en-US;q=0.5,en;q=0.3

Das ist sehr grundlegende Information und ich gehe darauf nur im Detail ein, weil ich oft genug Code gesehen habe, der den Wert behandelt, als würde er nur eine Sprache beinhalten, keine Liste, oder der die Gewichte ignoriert.

 

Der einfache Ansatz…

Alles, was wir tun müssen, ist, die gewünschte Sprache aus dem Header zu extrahieren und .NET mitzuteilen, und zwar für jeden Request:

   1: public class MvcApplication : System.Web.HttpApplication 

   2: { 

   3:     protected void Application_OnBeginRequest() 

   4:     { 

   5:         CultureHelper.ApplyUserCulture(this.Request); 

   6:     } 

   7: }

Die Klasse CultureHelper enthält die eigentliche Implementierung:

   1: public static void ApplyUserCulture(this HttpRequest request) 

   2: { 

   3:     ApplyUserCulture(request.Headers); 

   4: }

   5:  

   6: static void ApplyUserCulture(NameValueCollection headers) 

   7: { 

   8:     var culture = GetUserCulture(headers) ?? CultureInfo.GetCultureInfo(“en-US”); 

   9:  

  10:     var t = Thread.CurrentThread; 

  11:     t.CurrentCulture = culture; 

  12:     t.CurrentUICulture = culture; 

  13:     Debug.WriteLine("Culture: " + culture.Name); 

  14: }

Die eigentliche Bestimmung des Anwenderwunsches ist einfach:

   1: static CultureInfo GetUserCulture(NameValueCollection headers) 

   2: { 

   3:     // Accept-Language: de, en-US;q=0.8 

   4:     string acceptLanguages = headers["Accept-Language"]; 

   5:     if (string.IsNullOrEmpty(acceptLanguages)) 

   6:         return null; 

   7:     // split languages _and_ weights 

   8:     var cultureName = acceptLanguages.Split(',', ';')[0]; 

   9:     return GetCultureInfo(cultureName); 

  10: }

  11:  

  12: private static CultureInfo GetCultureInfo(string language) 

  13: { 

  14:     try 

  15:     { 

  16:         return CultureInfo.GetCultureInfo(language); 

  17:     } 

  18:     catch (CultureNotFoundException) 

  19:     { 

  20:         return null; 

  21:     } 

  22: }

Man beachte das Exception-Handling: Dies ist notwendig um die unterschiedlichen Vorstellungen von “Sprache” zwischen Client und Server abzufedern (vgl. der letzte Beitrag). In der Regel wird man kaum mal in die Situation kommen, dass der Anwender nach “klingonisch” fragt, aber es mag Ausnahmen geben. Weniger exotisch ist vielleicht (leider) die Möglichkeit von Angriffsszenarien…

Den Header-Wert auf diese Art auszulesen und .NET mitzuteilen ist etwas, das man in jedem Blog-Beitrag und Tutorial zum Thema findet. Tatsächlich müsste man sich nicht mal die Arbeit machen, denn ASP.NET tut das gerne automatisch (ebenfalls Scott’s Beitrag).

Leider greift das zu kurz!

 

Abstimmung des Anwenderwunsches und der unterstützten Regionen…

Ich möchte die erste Sprache, die der Anwender sich wünscht, gar nicht in jedem Fall verwenden…

Mein Anwendung wird englisch und deutsch unterstützen. Wenn ich die Region für meinen französischen Kollegen (der besser Deutsch kann als Englisch) auf fr-FR setzten würde, hätte das unerwünschte Konsequenzen:

  • Er würde englische Texte zu Gesicht bekommen, weil ich seine zweite Wahl deutsch ignoriere.
  • Er würde Datums-Werte sehen, die er womöglich falsch interpretiert. Dass “01/06/2013” (in einem englisch gehaltenen UI!) für den ersten Juni steht (Interpretation als TT/MM/JJJJ) ist alles andere als offensichtlich. Viel wahrscheinlicher ist die (falsche) Interpretation als Dreikönigstag (MM/TT/JJJJ).

Ergo ist es besser, ihm ein konsistentes UI (in diesem Fall en-US) darzustellen, als eine unglückliche Mixtur.

Was ich also möchte ist, dem Anwender das beste Angebot aus der Liste von mir unterstützten Sprachen zu machen, unter Berücksichtigung aller seiner Präferenzen, nicht nur der ersten.

Tatsächlich habe ich das noch selten adressiert und nirgendwo sauber umgesetzt gesehen. Immerhin hat z.B. Nadeem den Bedarf erkannt.

Um’s für die Implementierung deutlich zu machen die verschiedenen Fälle etwas im Detail. Dabei wird unterstellt, dass meine Anwendung en-US und de-DE unterstützt, mit en-US als Standard.

Vollständige Übereinstimmung:

  • Wenn der Anwenderwunsch "de-DE, en-US;q=0.8" ist,dann ist de-DE natürlich die beste Übereinstimmung. Analog ist bei "fr-FR, en-US;q=0.8", en-US die beste Wahl – nicht die erste Wahl des Anwenders, aber einer seiner Wünsche – der leider von vielen Beispiel-Implementierungen ignoriert wird.
  • Ein ebenfalls oft unterschlagener Fall: Wenn der Anwender nach "de, en-US;q=0.8" fragt, dann ist de-DE – obwohl es in der Liste gar nicht auftaucht – eine perfekte Übereinstimmung, denn zuallererst fragt der Anwender nach irgendeiner deutschsprachigen Darstellung.
  • Sollte er hingegen nach "de-AT, en-US;q=0.8" fragen, dann ist en-US die besser Wahl. Das mag diskussionswürdig sein, aber so kann der Anwender immerhin durch die Browsersettings Einfluss nehmen, was im umgekehrten Falle nicht möglich wäre.

Teilweise Übereinstimmung:

  • Sollte es keine vollständige Übereinstimmung geben, kann man dem Anwender immer noch mit teilweiser Übereinstimmung entgegen kommen: Bei der Anfrage nach "de-AT, fr-FR;q=0.8" macht es durchaus Sinn, ihm de-DE anzubieten, anstatt auf den (für ihn vielleicht unverständliche) Standard en-US zurückzufallen.

Keine Übereinstimmung:

  • Der Vollständigkeit halber: Wenn gar nichts passt, etwa "es-ES, fr-FR;q=0.8", dann kommt natürlich der Standard en-US zum Zug.

Es dürfte klar sein, dass dafür etwas mehr Code notwendig ist…

Analyse des Headers…

Der Accept-Language Header enthält eine Liste von gewichteten Einträgen. Zwar sind diese üblicherweise nach dem Gewicht sortiert, aber verlassen würde ich mich darauf nicht. Die Liste zu Parsen ist kein unlösbare Aufgabe, es erfordert nur etwas Arbeit:

   1: public static CultureInfo[] GetUserCultures(string acceptLanguage)

   2: {

   3:     // Accept-Language: fr-FR , en;q=0.8 , en-us;q=0.5 , de;q=0.3

   4:     if (string.IsNullOrWhiteSpace(acceptLanguage))

   5:         return new CultureInfo[] { };

   6:  

   7:     var cultures = acceptLanguage

   8:         .Split(',')

   9:         .Select(s => WeightedLanguage.Parse(s))

  10:         .OrderByDescending(w => w.Weight)

  11:         .Select(w => GetCultureInfo(w.Language))

  12:         .Where(ci => ci != null)

  13:         .ToArray();

  14:     return cultures;

  15: }

Dazu die Hilfsklasse WeightedLanguage:

   1: class WeightedLanguage

   2: {

   3:     public string Language { get; set; }

   4:     public double Weight { get; set; }

   5:  

   6:     public static WeightedLanguage Parse(string weightedLanguageString)

   7:     {

   8:         // de

   9:         // en;q=0.8

  10:         var parts = weightedLanguageString.Split(';');

  11:         var result = new WeightedLanguage { Language = parts[0].Trim(), Weight = 1.0 };

  12:         if (parts.Length > 1)

  13:         {

  14:             parts[1] = parts[1].Replace("q=", "").Trim();

  15:             double d;

  16:             if (double.TryParse(parts[1], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out d))

  17:                 result.Weight = d;

  18:         }

  19:         return result;

  20:     }

  21: }

Die unterstützten Regionen…

Man kann das natürlich konfigurierbar machen, aber im einfachsten Fall wird die Liste der unterstützten Regionen einfach im Code festgeschrieben. Standard-Region ist der erste Eintrag:

   1: public static readonly CultureInfo[] SupportedCultures = new CultureInfo[] 

   2: { 

   3:     CultureInfo.GetCultureInfo("en-US"),

   4:     CultureInfo.GetCultureInfo("de-DE"),

   5:     //CultureInfo.GetCultureInfo("fr-FR"),

   6:     //CultureInfo.GetCultureInfo("es-ES"),

   7: };

Matching…

Fehlt noch die eigentliche Arbeit, das Matching der vom Anwender angeforderten und der unterstützten Region. Ändern wir also die oben gezeigte Methode entsprechend ab:

   1: public static CultureInfo GetUserCulture(NameValueCollection headers)

   2: {

   3:     var acceptedCultures = GetUserCultures(headers["Accept-Language"]);

   4:     var culture = GetMatchingCulture(acceptedCultures, SupportedCultures);

   5:     return culture;

   6: }

Für das eigentliche Matching brauchen wir zwei Durchläufe, einmal für die vollständige, einmal für die teilweise Übereinstimmung:

   1: public static CultureInfo GetMatchingCulture(CultureInfo[] acceptedCultures, CultureInfo[] supportedCultures)

   2: {

   3:     return

   4:         // first pass: exact matches as well as requested neutral matching supported region

   5:         //      supported: en-US, de-DE

   6:         //      requested: de, en-US;q=0.8  

   7:         //                  => de-DE!  (de has precendence over en-US)

   8:         GetMatch(acceptedCultures, supportedCultures, MatchesCompletely)

   9:         // second pass: look for requested neutral matching supported _neutral_ region

  10:         //      supported: en-US, de-DE

  11:         //      requested: de-AT, en-GB;q=0.8  

  12:         //                  => de-DE! (no exact match, but de-AT has better fit than en-GB)

  13:         ?? GetMatch(acceptedCultures, supportedCultures, MatchesPartly);

  14: }

Die eigentliche Arbeitsmethode ist GetMatch. Sie sucht für alle angeforderten Sprachen oder Regionen eine passende unterstützte Region und liefert den ersten Fund als Ergebnis:

   1: public static CultureInfo GetMatch(

   2:     CultureInfo[] acceptedCultures, 

   3:     CultureInfo[] supportedCultures,

   4:     Func<CultureInfo, CultureInfo, bool> predicate)

   5: {

   6:     foreach (var acceptedCulture in acceptedCultures)

   7:     {

   8:         var match = supportedCultures

   9:             .Where(supportedCulture => predicate(acceptedCulture, supportedCulture))

  10:             .FirstOrDefault();

  11:         if (match != null)

  12:             return match;

  13:     }

  14:     return null;

  15: }

Fehlt nur noch der Abgleich zweier CulturInfos. Für die vollständige Übereinstimmung muss der Fall der angeforderten Sprache (neutral culture) berücksichtigt werden:

   1: static bool MatchesCompletely(CultureInfo acceptedCulture, CultureInfo supportedCulture)

   2: {

   3:     if (supportedCulture.Name == acceptedCulture.Name)

   4:         return true;

   5:     // acceptedCulture could be neutral and supportedCulture specific, but this is still a match (de matches de-DE, de-AT, ...)

   6:     if (acceptedCulture.IsNeutralCulture)

   7:     {

   8:         if (supportedCulture.Parent.Name == acceptedCulture.Name)

   9:             return true;

  10:     }

  11:     return false;

  12: }

Für die teilweise Übereinstimmung werden nur die Sprachen verglichen:

   1: static bool MatchesPartly(CultureInfo acceptedCulture, CultureInfo supportedCulture)

   2: {

   3:     supportedCulture = supportedCulture.Parent;

   4:     if (!acceptedCulture.IsNeutralCulture)

   5:         acceptedCulture = acceptedCulture.Parent;

   6:  

   7:     if (supportedCulture.Name == acceptedCulture.Name)

   8:         return true;

   9:     return false;

  10: }

Fertig.

Halt! Anzeigen sollten wir das wenigstens noch, etwa in der Home/Index View:

   1: Current culture is: @UICulture

Die View-Basisklasse stellt uns den Namen der Region als UICulture schlüsselfertig zur Verfügung.

Aber jetzt: Fertig!

Eine ganze Menge Code, den man eigentlich in der Plattform erwarten würde… Zähnezeigendes Smiley