Thomas Bandt

Über mich | Kontakt | Archiv

Clientseitige Lokalisierung mit .NET und knockout.js

Lokalisierung ist ein Thema, das jeden Entwickler sein ganzes Leben lang begleitet. Lösungsansätze gibt es viele verschiedene, einen ganz speziellen, und in meinen Augen auch eleganten, möchte ich nachfolgend kurz vorstellen. Speziell ist er deshalb, weil er eine Anforderung abdeckt, die sie mir nun nach 10 Jahren Webentwicklung das erste Mal wirklich gestellt hat.

Bisher wurde jegliche Übersetzung vom Server erledigt, d.h. die Texte wurden schon in der jeweiligen Sprache an den Browser ausgeliefert. Das geht in meinem aktuellen Projekt nicht - denn diese Anwendung arbeitet ab einem bestimmten Zeitpunkt komplett offline. Da wäre es dem Nutzer nicht zu vermitteln, dass er zum Wechseln der Sprache eine Internetverbindung benötigt.

Das heißt, dass das Wechseln der Sprache clientseitig erfolgen muss und auch die Entscheidung, in welcher Sprache ein Text in einem Dialog angezeigt wird, am Client getroffen wird.

So lang es nur um Texte geht, die aus JavaScript heraus verwendet werden, z.B. für Message-Boxen, ist das kein großes Problem: irgendwo ein Dictionary hinterlegen, cachen, abfragen, anzeigen, fertig. Aber da nun tatsächlich erst clientseitig die Entscheidung fällt, welche Sprache verwendet wird, müssen auch die Teile, die normalerweise statisch sind, zur Laufzeit erzeugt werden - Hinweistexte, Labels, Legenden usw.

Und hier wird es kniffelig: wie bekommt man am saubersten Text dynamisch in eine statische HTML-Seite eingebunden, ohne für jeden Punkt und Strich alles mit document.write() und entsprechenden Script-Tags zu verstopfen?

Grundlage - Resource-Files

Hier hat sich seit knapp 10 Jahren nicht viel getan - wenn mich meine Erinnerung nicht trügt, hat sich auch das Tooling rund um die .resx-Dateien in Visual Studio nicht verbessert, es ist heute noch genauso schlecht wie schon mit Visual Studio 2002. Trotzdem finde ich das Konzept an sich recht brauchbar und um einiges flexibler als z.B. die Ablage von Texten in der Datenbank (wie immer: it depends).

Und mit dem Zeta Resource Editor steht ein Tool zur Verfügung, das all die Schwachstellen von Visual Studio im Umgang mit den Resource Files beseitigt. Es gibt eine komfortable Editier-Möglichkeit, und, was das allerwichtigste ist, einen Im- und Export für Excel. Kaum ein Übersetzer, den man irgendwo engagiert, wird sich eine Software aufschwatzen lassen - deshalb ist Excel als Schnittstelle eminent wichtig.

Die Übersetzungen müssen zum Client ...

Resource Files als Grundlage haben den großen Vorteil, dass sie sowohl server- als auch clientseitig einsetzbar sind. Man muss also nicht zwei verschiedene Quellen für die Übersetzungen pflegen und kann durchaus für beide Seiten die selben Dateien benutzen. Nur muss der Inhalt auch zum Client, und das geht so:

   1:  public class LocalizedLabelViewModel
   2:  {
   3:      public string Label { get; set; }
   4:      public Dictionary<string, string> Translations = new Dictionary<string, string>();
   5:  }
   6:   
   7:  public class HomeController : Controller
   8:  {
   9:   
  10:      public ActionResult LocalizedLabels()
  11:      {
  12:   
  13:          var labels = new List<LocalizedLabelViewModel>();
  14:          PropertyInfo[] properties = typeof(Localization.Labels).GetProperties(BindingFlags.Static | BindingFlags.Public);
  15:   
  16:          foreach (var property in properties.Where(p => p.Name != "ResourceManager" && p.Name != "Culture"))
  17:          {
  18:   
  19:              var label = new LocalizedLabelViewModel();
  20:              label.Label = property.Name;
  21:   
  22:              Localization.Labels.Culture = new CultureInfo("de-DE");
  23:              label.Translations.Add(Localization.Labels.Culture.Name, property.GetValue(null, null).ToString());
  24:   
  25:              Localization.Labels.Culture = new CultureInfo("en-GB");
  26:              label.Translations.Add(Localization.Labels.Culture.Name, property.GetValue(null, null).ToString());
  27:   
  28:              labels.Add(label);
  29:   
  30:          }
  31:   
  32:          return PartialView(labels);
  33:   
  34:      }
  35:   
  36:  }

Die einzelnen Labels werden also via Reflection geholt und und in allen Sprachversionen (manuell) gesammelt. Die nachfolgende View sieht furchtbar aus - aber das kommt halt dabei heraus, wenn man versucht mit Razor JavaScript zu erzeugen:

   1:  @model IEnumerable<JsLocalizationDemo.Model.LocalizedLabelViewModel>
   2:  @{
   3:      Response.ContentType = "text/javascript";
   4:      var indexLabels = 0;
   5:      int labelsCount = Model.Count();
   6:  }var localizedLabels = {
   7:      @foreach (var label in Model) {
   8:      indexLabels++;
   9:      @label.Label@:: {
  10:      var indexTranslations = 0;
  11:      foreach (var translation in label.Translations) {
  12:          indexTranslations++;
  13:          @:'@translation.Key': '@Html.Raw(translation.Value.Replace("'", "\\'"))'@(indexTranslations < label.Translations.Count ? "," : "")
  14:      }
  15:      @:}@(indexLabels < labelsCount ? "," : "")
  16:  }}

Das Ergebnis kann sich jedenfalls besser sehen lassen:

Ausgabe der Übersetzungen

Der Vorteil dieses Weges gegenüber der Verwendung von "echtem" Json (was übrigens die Aufbereitung in der View auf 1 Zeile minimieren würde) ist die Möglichkeit mit der Bezeichnung des Labels als Index arbeiten zu können:

   1:  function localizedText(label) {
   2:      try {
   3:          return localizedLabels[label]["de-DE]";
   4:      }
   5:      catch (e) {
   6:          return "n/a";
   7:      }
   8:  }

Integration in die View - mit knockout.js

Um die Texte nun an beliebigen Stellen innerhalb der View verwenden zu können, hilft knockout.js. Die einfachste aller Möglichkeiten wäre den Standard-Text-Binding-Handler zu verwenden, und so den Text auszugeben:

   1:  <label data-bind="text: localizedText('UserName')"></label>

Doch es geht auch noch einen Tick eleganter. knockout.js bietet die Möglichkeit eigene Binding-Handler zu definieren:

   1:  ko.bindingHandlers.localizedText = {
   2:      init: function (element, valueAccessor) {
   3:          $(element).text(localizedText(valueAccessor()));
   4:      }
   5:  };

Womit dann auch die Anwendung sehr einfach wird:

   1:  <label for="userName" data-bind="localizedText: 'UserName'"></label>

Diese Bindings lassen sich nun auf sämtliche HTML-Elemente anwenden, die einen "Inner Text" besitzen. Abwandlungen und Erweiterungen sind natürlich beliebig möglich - ich habe z.B. noch einen Handler für das Placeholder-Attribut benutzt, das mit HTML5 für Textboxen eingeführt wird.

Fazit

Als Grundlage Altbewährtes, im Frontend knockout.js und schon hat man eine Möglichkeit HTML-Anwendungen zu lokalisieren, ohne dass der Server die entscheidende Rolle spielen muss. Ich habe auf dem Weg innerhalb eines Tages eine recht komplexe App mehrsprachig gemacht und bin zufrieden - es ist sehr flexibel, zuverlässig und es funktioniert z.B. auch mit jQuery.templ(), d.h. Übersetzungen sind auch mit Templates kein Problem.

Im Anhang findet sich noch eine kleine MVC3-App, die sowohl die grundsätzliche Funktionsweise von knockout.js als auch die Übersetzung im Ganzen demonstriert.

Update

Ilker hat mich darauf hingewiesen, dass es auch mit "echtem" Json funktioniert - was natürlich wesentlich smarter ist. Als ich mir sein Beispiel angesehen habe, wusste ich auch wieder, warum das so nicht funktioniert hat: bei obigem ViewModel stehen Index (Label) und Übersetzungen nebeneinander, die Übersetzungen sind also dem Label nicht untergeordnet.

Also habe ich das ViewModel weggeworfen und einfach mit zwei verschachtelten Dictionaries gearbeitet:

   1:  public ActionResult LocalizedLabels()
   2:  {
   3:   
   4:      var labels = new Dictionary<string, Dictionary<string, string>>();
   5:   
   6:      PropertyInfo[] properties = typeof(Localization.Labels).GetProperties(BindingFlags.Static | BindingFlags.Public);
   7:   
   8:      foreach (var property in properties.Where(p => p.Name != "ResourceManager" && p.Name != "Culture"))
   9:      {
  10:   
  11:          var translations = new Dictionary<string, string>();
  12:   
  13:          Localization.Labels.Culture = new CultureInfo("de-DE");
  14:          translations.Add(Localization.Labels.Culture.Name, property.GetValue(null, null).ToString());
  15:   
  16:          Localization.Labels.Culture = new CultureInfo("en-GB");
  17:          translations.Add(Localization.Labels.Culture.Name, property.GetValue(null, null).ToString());
  18:   
  19:          labels.Add(property.Name, translations);
  20:   
  21:      }
  22:   
  23:      return PartialView(labels);
  24:   
  25:  }

 Das reduziert die View auf ein Minimum:

   1:  @model Dictionary<string, Dictionary&lt;string, string>>
   2:  @{
   3:      Response.ContentType = "text/javascript";
   4:  }
   5:  var localizedLabels = @Html.Raw(Json.Encode(Model));

Alex' Tipp aus den Kommentaren habe ich hingegen nicht aufgenommen - da ich, würde ich das Json direkt ausliefern, das wieder irgendwo abholen müsste. So spare ich mir den zusätzlichen Request und weise das Ganze direkt zu.

Jetzt ist es doch wirklich rund, danke Ilker :-). Eine aktualisierte Demo hängt ebenfalls an.

Downloads

Kommentare

  1. Alex schrieb am Dienstag, 30. August 2011 21:12:00 Uhr:

    Schönes Beispiel ;-)

    Auf die View zur Erzeugung des JSON kannst Du im Prinzip komplett verzichten, wenn Du einfach einen StringBuilder im Controller verwendest und dessen ToString() als ContentResult (return Content(sb.ToString()) mit entsprechendem Response.ContentType auslieferst.

    Aber das ist letztlich nur Gusto...
  2. Marius Schulz schrieb am Mittwoch, 31. August 2011 01:30:00 Uhr:

    Gutes Beispiel zur Lokalisierung!

    Mir ist im Quellcode der index.cshtml eine Sache aufgefallen: Weshalb verwendest Du @Url.Content("~/home/localizedlabels") und nicht @Url.Action("Localizedlabels", "Home")?
  3. Thomas schrieb am Mittwoch, 31. August 2011 08:52:00 Uhr:

    "Tippfehler" - hat keinen sinnvollen Grund.
  4. Christian Hünniger schrieb am Donnerstag, 1. September 2011 23:59:00 Uhr:

    Hi Thomas,

    genialer Beitrag :-)


    leider einen kleinen Tippfehler gefunden (kannst Du auch behalten):

    return localizedLabels[label]["de-DE]"
  5. Thomas schrieb am Freitag, 2. September 2011 09:18:00 Uhr:

    Das kommt davon, wenn man nachträglich noch dran rumpfuscht - na hauptsache in der Beispielanwendung ist es nicht drin :-).


« Zurück  |  Weiter »