Maatwerk vertalingen voor bv. ASP.Net MVC2 applicaties

Inleiding

Het bouwen van applicaties welke meerdere talen moeten
ondersteunen is altijd een lastige en tijdrovende klus. Los van het risico dat
afhankelijk van de context een onjuiste vertaling getoond wordt, moeten uit de
hele applicatie geen alle hard-coded teksten verwijderd worden.
De meest gangbare ondersteuning voor vertalingen is die van resource bestanden. Hierbij worden voor iedere te ondersteunen taal een lijst van vertalingen aangelegd. Deze worden tot aparte resourcefiles ‘omgesmolten’ en mede uitgeleverd naar de productieomgeving of in de uit te rollen installatie.

Neem eens een website als voorbeeld. Naast dat het genereren van resource bestanden tijdrovend is, wordt er ook een last op Systeembeheer gelegd. Want iedere keer als de vertalingen aangepast worden, moeten de resources opnieuw uitgerold worden. Maar OK, dit is een oplossing.

Bij mijn huidige klant is gekozen voor een andere manier van vertalen bij hun klassieke Asp.Net websites. Zij maken gebruik van tabellen in een database om de vertalingen te ondersteunen. Deze vertalen worden daarna in de web cache ingeladen en bewaard. Wijzigingen in de database worden dus vanzelf zichtbaar in de website.

Lost in translation

Ik ben de laatste tijd veel bezig met Asp.Net MVC2 en liep tegen dezelfde
problematiek aan. Wij hebben eerst de applicatie volledig in het Engels
geschreven. En alles was hardcoded, over vertalen werd niet gesproken. Maar
plotseling wilde de klant de site toch in het Nederlands vertaald hebben. Dit is
een simpele omschakeling maar kost heel veel tijd als dit hardcoded opgelost
gaat worden.

Asp.Net MVC2 kent gelukkig de resource bestanden aanpak maar ik
zocht meer naar een oplossing via de database. Helaas was de klassieke oplossing niet te hergebruiken. Die zat allemaal in maatwerk Asp.Net controls
ingebakken.

Ik ben dus eens op zoek gegaan naar een andere oplossing om met
een database te werken. Gelukkig kon ik Alex Thissen van Achmea hierover uitvragen op de SDE van vorige week. Hij wees mij op het provider model waardoor het mogelijk zou moeten zijn om een eigen resource provider te bouwen en te registreren.

Na wat zoekwerk en het betere ‘hackwerk’ ben ik tot een eenvoudige oplossing gekomen waarbij ik de volledige beheersing heb gekregen over de vertalingen.

Een voorbeeld in Asp.Net MVC2

Laten we eens naar een kale Asp.Net MVC2 applicatie kijken:

Kale Asp.Net MVC2 applicatie

Kijk eens naar dit standaard gegenereerde scherm. Alles, maar dan ook
alles wat hier getoond wordt, is hard gecodeerd, in code of in de aspx
bestanden. En ik ben dus op zoek gegaan naar een relatief eenvoudige, schaalbare manier om dit alles te vertalen. Ik stuitte al snel op deze blog: http://www.codeproject.com/KB/aspnet/customsqlserverprovider.aspx

Dit is als uitgangspunt genomen. In het betreffende artikel wordt een
prima oplossing aangeboden maar ik heb geprobeerd om zo veel mogelijk weg te
laten. En dit is wat er is overgebleven:

using System.Web.Compilation;
using System.Globalization;
using System.Resources;
namespace CustomResourceMvcApplication
{
  public sealed class CustomResourceProviderFactory :
    ResourceProviderFactory
  {
    public override IResourceProvider
            CreateGlobalResourceProvider(string classKey)
    {
      return new CustomResourceProvider(null, classKey);
    }

    public override IResourceProvider
       CreateLocalResourceProvider(string virtualPath)
    {
      return new CustomResourceProvider(virtualPath, null);
    }
  }

  sealed class CustomResourceProvider : IResourceProvider
  {
    public CustomResourceProvider(
           string virtualPath, string className) { }

    object IResourceProvider.GetObject(string resourceKey,
           CultureInfo culture)
    {
      string name = string.Empty;

      if (culture != null)
      {
        name = culture.Name;
      }
      else
      {
        name = CultureInfo.CurrentCulture.Name;
      }

      return "TODO." + name + "." + resourceKey;
    }

    IResourceReader IResourceProvider.ResourceReader
    {
      get
      {
        return null;
      }
    }
  }
}

En inderdaad, het is eigenlijk niet zoveel code meer. Het enige wat ik
nog gebruik is een eigen Resource provider afgeleide die via een Factory voor
mij geïnstancieerd wordt. Binnen de ResourceProvider wordt de methode GetObjects aangeroepen waarbij ik de taal en een key doorgegeven krijg. Het uitlezen van de database voor de resources kan nu zelf ingevuld worden. In dit voorbeeld wordt de opgevraagde resource key voorafgegaan door TODO en de opgevraagde taal. Deze code zal dus aangevuld moeten worden met het echt uitlezen van bv. een database maar dit laat ik hier in dit voorbeeld achterwege.

Maar met deze code alleen zijn we er nog niet. We maken gebruik
van het providermodel dus we moeten onze eigen provider nog registreren in de
web.config:

...
<globalization
     culture="nl-NL"resourceProviderFactoryType=
     "CustomResourceMvcApplication.CustomResourceProviderFactory" />
</system.web>

Hier wordt onze eigen Factory geregistreerd. Bij het starten van de
applicatie zal nu onze eigen factory toegepast worden waarbij voor alles de
Nederlandse vertaling opgevraagd wordt. We kunnen nu dus de resources aanspreken via code in de controller:

Public class HomeController : Controller
{
  public ActionResult Index()
  {
    ViewData["Message"] = (string)
       HttpContext.GetLocalResourceObject(
            "VirtualPathFromController",
            "MessageFromControllerCode");
    return View();}
 }
...

Maar in Asp.Net MVC2 worden ook teksten toegepast buiten de code in de
controllers om. Ook de ‘hardcoded’ teksten in de aspx views kunnen we vervangen door vertaalde teksten via de resources. Dit wordt door de viewengine
opgepakt:

<asp:Content ID="indexTitle"
    ContentPlaceHolderID="TitleContent" runat="server">
<asp:Literal runat="server"
     Text="<%$Resources:CommonTerms, HomeTitle%>" />
</asp:Content>

Maar ditzelfde mechanisme kan niet gebruikt worden binnen de ActionLinks.
Dit zijn de knoppen voor schermovergangen. Deze worden al door de viewengine
vervangen. Maar ActionLinks zijn ExtensionMethods, geen classes, dus die kunnen niet overerft worden. Dus moeten we nieuwe, alternatieve, ExtensionMethods schrijven die de originele ExtensionMethods hergebruiken:

using System.Threading;
namespace System.Web.Mvc.Html
{
  public static class HtmlHelperExtensions
  {
    public static MvcHtmlString ActionLinkLocalized(
       this HtmlHelper helper, string linkText, string actionName)
    {
      string linkTextTranslated =
         HttpContext.GetGlobalResourceObject(
           "ActionLinkCustomized",
           linkText,
           Thread.CurrentThread.CurrentCulture).ToString();

      return helper.ActionLink(linkTextTranslated, actionName);
    }

    public static MvcHtmlString ActionLinkLocalized(
        this HtmlHelper helper,
        string linkText, string actionName, string controllerName)
    {
      string linkTextTranslated =
        HttpContext.GetGlobalResourceObject(
          "ActionLinkCustomized",
          linkText,
          Thread.CurrentThread.CurrentCulture).ToString();
      return helper.ActionLink(
        linkTextTranslated, actionName, controllerName);
    }
  }
}

Het is wel handig om dezelfde namespace te gebruiken. Dit zijn wellicht
niet de enige ExtensionMethods die aangepast moeten worden, maar je ziet in
ieder geval het patroon…

De aanroep in de View (of in dit geval in de site.master) wordt dan:

  <li><%= Html.ActionLinkLocalized("Home", "Index", "Home")%></li>
  <li><%= Html.ActionLinkLocalized("Insert", "Insert", "Home")%></li>
  <li><%= Html.ActionLinkLocalized("About", "About", "Home")%></li>

En hiermee wordt de pagina als volgt:

Alles is vertaald

Zoals je ziet, alle teksten zijn nu vervangen. Best wel indrukwekkend, toch? Zelfs de titel van de pagina is te manipuleren.

Entiteiten vertalen

Maar een gemiddelde Asp.Net MVC2 applicatie bestaat niet alleen
maar uit Controllers en Views. Er is ook een Model in het spel en die bevat dan
meestal een aantal entiteiten. Bij een “Fat Model ” behoren namelijk oa. de
teksten van de labels en de validaties. Dus we willen ook de teksten bij de
entiteiten laten vertalen:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel;
namespace CustomResourceMvcApplication.Models
{
  [MetadataType(typeof(Klant_Metadata))]
  public partial class Klant  {  }

  public class Klant_Metadata
  {
    [Required()]
    [DisplayName("Klantnaam")]
    public object Naam { get; set; }

    [DisplayName("Klantadres")]
    public object Adres { get; set; }

    [DisplayName("Klantwoonplaats")]
    public object Woonplaats { get; set; }
  }
}

Voor diegene die dit wat verwarrend vindt, zal ik even uitleggen wat hier
staat. Ik heb via het Entity Framework een class Klant laten genereren. Deze
code kan ik niet aanpassen want bij het opnieuw genereren raak ik de wijzigingen kwijt. Gelukkig is de gegenereerde class als partial gedefinieerd dus kan ik die wel uitbreiden. Ik heb dus een tweede partial class Klant geschreven die zal versmelten met de gegeneerde class.

Maar ik kan binnen mijn Klant class niet de properties herhalen om daar attributen op te zetten. Daarvoor is een buddy class gedefinieerd die via de MetadataType attribuut toegewezen wordt. Daarin herhaal ik wel de kolommen waar ik de attributen op wil plaatsen. Zie ook dat het type van de properties als object is gedefinieerd. Het is namelijk niet relevant, via reflection zal de juiste kolom achterhaald worden op basis van de property naam.

Zoals je ziet, heb ik zelf een aantal labels vertaald en de validatie op een verplichte kolom toegepast. De tekst daarvan is de standaard gegeneerde tekst (in het Engels). Dit ziet er dus zo uit in een gemiddelde Asp.Net MVC2 applicatie:

Mvc2 Entiteiten worden standaard niet vertaald

De standaard manier van vertalen via de overloaded methods kreeg ik niet aan
de praat. Dit blijkt meer voor te komen, want voor de label attributen is al een
oplossing beschreven op het Web. Deze heb ik gevonden op http://holyhoehle.wordpress.com/category/asp-net-mvc/

using System.Web;
using System.ComponentModel;
using System.Threading;
namespace CustomResourceMvcApplication.Models
{
  public class DisplayNameLocalizedAttribute : DisplayNameAttribute
  {
    private readonly string m_ResourceName;
    private readonly string m_ClassName;

    public DisplayNameLocalizedAttribute(
                  string className, string resourceName)
    {
      m_ResourceName = resourceName;
      m_ClassName = className;
    }

    public override string DisplayName
    {
      get
      {
        // get and return the resource object
        return HttpContext.GetGlobalResourceObject(
           m_ClassName,
           m_ResourceName,
           Thread.CurrentThread.CurrentCulture).ToString();
      }
    }
  }
}

Hierbij wordt het Attribuut overerft en voorzien van een alternatieve identificatie voor de te vertalen tekst. De classname wordt hier overigens genegeerd in dit voorbeeld.

Restte mij nog de validatie op het verplichte veld. Voor de specifieke validatie op verplichte velden heb ik zelf een afgeleide op de
standaard RequiredAttribute moeten schrijven:

using System.Web;
using System.Threading;
using System.ComponentModel.DataAnnotations;
namespace CustomResourceMvcApplication.Models
{
  public class RequiredLocalizedAttribute : RequiredAttribute
  {
    private readonly string m_ResourceName;
    private readonly string m_ClassName;
    public RequiredLocalizedAttribute(
          string className, string resourceName)
    {
      m_ResourceName = resourceName;
      m_ClassName = className;
    }

    public override string FormatErrorMessage(string name)
    {
      string textToFormat = HttpContext.GetGlobalResourceObject(
           m_ClassName,
           m_ResourceName,
           Thread.CurrentThread.CurrentCulture).ToString();
      // get and return the formatted resource object
      return string.Format(textToFormat, name);
    }
  }
}

Aan formatErrorMessage wordt overigens de tekst van de al vertaalde displayname van de kolom doorgegeven. Hier kan dus {0} voor toegepast worden bij  het formatteren.

En hierdoor wordt de buddy class een klein beetje anders:

...
{
  ...
  public class Klant_Metadata
  {
    [RequiredLocalized("MetaDataRequiredClassName", "KlantnaamVerplicht")]
    [DisplayNameLocalized("MetaDataDisplayNameClassName", "Naam")]
    public object Naam { get; set; }

    [DisplayNameLocalized("MetaDataDisplayNameClassName", "Adres")]
    public object Adres { get; set; }

    [DisplayNameLocalized("MetaDataDisplayNameClassName", "Woonplaats")]
    public object Woonplaats { get; set; }
  }
}

En daardoor zijn al deze labels en validaties door vertalingen ondersteund.
Dit betekent dus dat alle andere type validaties (RangeAttribute,
RegularExpressionAttribute, StringLengthAttribute, etc.) ook hun specifieke
overerving moeten krijgen indien deze ook toegepast moeten worden.

Als we nu naar de Insert View kijken zien we de volgende teksten:

Mvc2 Entiteiten (labels en validaties) worden nu ook vertaald

Ook hier kunnen dus alle teksten vervangen worden door vertaalde varianten.

Conclusie

Zoals in de inleiding al gezegd, vertalingen zijn een bron van ergernis. Maar met bovenstaande implementatie wordt het een stukje eenvoudiger en beheersbaarder. Natuurlijk moeten nog het aantal ondersteunde Entity
attributen uitgebreid worden en ook aan de HtmlHelper kant moet nog eea.
gebeuren. Maar de basis is flexibel om tot een fraai vertaalde applicatie te
komen welke flexibel te vertalen is en minder kosten voor deployment met zich
meebrengt.

In dit voorbeeld is als basis een Asp.Net MVC2 applicatie genomen
maar dit moet ook voor andere typen applicaties toepasbaar zijn.