Lokalizacja Enum

Wprowadzenie

Typów wyliczeniowych używamy do określenia wartości z góry określonego zbioru. Dzięki nim ułatwiamy sobie tworzenie kodu poprzez nadanie nazwy dla wartości. Nazwa enuma musi spełniać rygory składni takich języków, jak C#. Dlatego potrzebujemy czegoś, co wyświetli ładny napis w interfejsie użytkownika. W tym wpisie, drogi Czytelniku, pokażę Ci, jak to zrobić małym nakładem pracy oraz przy okazji wspierać wiele języków. Zobacz, jak lokalizować enumy w aplikacji .NET.

Status zamówienia

W przykładzie zajmiemy się zamówieniami oraz ich statusami. Prosta aplikacja ASP.NET MVC, którą przygotowałem, wyświetli listę zamówień, w której jednym z pól będzie status zamówienia będący właśnie typem wyliczeniowym:

public enum OrderStatus
{
Created,
Completed,
Canceled
}
public class Order
{
public string Number { get; set; }
public OrderStatus Status { get; set; }
}
view raw Order.cs hosted with ❤ by GitHub

Zamówienie może być w jednym z trzech statusów. W aplikacji chcielibyśmy wyświetlać status zamówienia w języku polskim i najlepiej, aby nie trzeba było przy tym pisać za każdym razem dużej ilości kodu.

Użycie view modeli

W swoich aplikacjach wykorzystuję view modele do przekazywania danych do widoków (właściwie w każdym typie aplikacji z tego korzystam). Zakładam, że widoki w aplikacji są w miarę proste i nie chcę mieć w nich za dużo logiki, szczególnie że widoki trudno przetestować. Dlatego view model przekazany do widoku zawiera już właściwy napis dla enuma. View model dla zamówienia wygląda tak:

public class OrderViewModel
{
public string Number { get; set; }
public string Status { get; set; }
}

Jak widać na listingu, właściwość Status w view modelu jest typu string. Jest to o tyle istotne, że dzięki temu za chwilę automapper wykona automatyczną konwersję, która zamieni enuma na jego ładny napis.

Atrybut EnumDescription

Poszczególne napisy dla enumów przechowuję w plikach Resource. Dzięki temu mogę tłumaczyć je i wspierać wiele języków w aplikacji. Dla każdego enuma tworzę dedykowany plik resource, np. OrderStatusResources. Używam wartości enuma jako nazw elementów w resource’u:

OrderStatusResources

Następnie enum jest udekorowany moim własnym atrybutem (EnumDescription), w którym przekazywany jest plik resource zawierający napisy:

public class EnumDescriptionAttribute : Attribute
{
public Type ResourceType { get; set; }
public EnumDescriptionAttribute(Type resourceType)
{
ResourceType = resourceType;
}
}
[EnumDescription(typeof(OrderStatusResources))]
public enum OrderStatus
{
Created,
Completed,
Canceled
}

Kiedyś zamiast własnego atrybutu korzystałem z atrybutu Display z data annotations. Niestety tamten atrybut nie może być podpięty pod typ i trzeba go definiować dla każdego elementu w typie. Takie podejście na dłuższą metę jest uciążliwe, dlatego teraz stosuję swój atrybut, który jest używany tylko raz dla typu.

ToDisplayString – extension method

Do wyciągania przetłumaczonego napisu wykorzystuję extension method (ToDisplayString):

public static class EnumExtensions
{
public static string ToDisplayString(this Enum value)
{
if (value == null)
{
throw new ArgumentNullException("value");
}
string description = value.ToString();
EnumDescriptionAttribute[] attributes = (EnumDescriptionAttribute[])value.GetType().GetCustomAttributes(typeof(EnumDescriptionAttribute), false);
if (attributes != null && attributes.Length > 0)
{
Type resourceType = attributes[0].ResourceType;
if (resourceType != null)
{
var property = resourceType.GetProperty(description);
if (property != null)
{
var propertyValue = property.GetValue(null);
if (propertyValue != null)
{
description = propertyValue.ToString();
}
}
}
}
return description;
}
}

Metoda sprawdza w pierwszej kolejności, czy enum jest udekorowany atrybutem EnumDescription, i jeśli tak, to szuka w przekazanym pliku resource’ów wpisu o takie samej nazwie, jak wartość enuma. Zwraca go, gdy go znajdzie. Jeśli nie znajdzie, zwraca nazwę enuma. Użycie tej metody jest później bardzo proste:

var status = OrderStatus.Completed;
var display = status.ToDisplayString();

W tym miejscu dodaję również logowanie informacji (w przykładzie tego nie ma) o brakujących resource’ach dla enuma – aby przez przypadek nie zapomnieć ich dodać.

Taka implementacja ToDisplayString nie jest efektywna. Korzystanie z refleksji przy każdym wywołaniu powoduje niepotrzebny narzut. Pod koniec miesiąca planuję dodać wpis, w którym wrócę do tej metody i zastanowimy się, co można zrobić lepiej.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Automapper oraz TypeConverter

Na początku artykułu obiecałem, że użycie tego mechanizmu będzie wiązało się z małym nakładem pracy. Posłużymy się więc automapperem, w którym możemy zdefiniować konwerter dla typów, który automatycznie będzie zmieniał enuma na napis. Użyjemy do tego metody ToDisplayString. Plusem tego rozwiązania jest to, że nie będziemy musieli później pisać kodu, który będzie wywoływał metodę ToDisplayString. Wszystko zrobi za nas automapper. Będziemy musieli jedynie zapewnić, że view model używa typu string dla właściwości, aby automatyczna konwersja mogła mieć miejsce.

Tworzenie konwertera typów jest proste. Wystarczy utworzyć nową klasę oraz zaimplementować generyczny interfejs ITypeConverter. W naszym przypadku będzie to wywołanie metody ToDisplayString:

public class EnumToStringConverter : ITypeConverter<Enum, string>
{
public string Convert(Enum source, string destination, ResolutionContext context)
{
return source.ToDisplayString();
}
}

Mając już konwerter typów, rejestrujemy go w profilu, aby był wykorzystywany do konwersji enuma na string:

public class EnumsProfile : Profile
{
public EnumsProfile()
{
CreateMap<Enum, string>()
.ConvertUsing<EnumToStringConverter>();
}
}

I to już wszystko. Nie musimy już w innych mapowaniach (np. Order => OrderViewModel) dodawać informacji o sposobie zamiany enuma na string. Wszystko zrobi za nas automapper.

Poniżej rejestracja mapowania dla zamówienia. Jak widać, nie określam w tym miejscu, jak mapować status.

public class OrdersProfile : Profile
{
public OrdersProfile()
{
CreateMap<Order, OrderViewModel>();
}
}

Przykład

Na githubie (https://github.com/danielplawgo/EnumLocalization) znajduje się przykład pokazujący ten mechanizm w praktyce. W przykładzie znajduje się jedna testowa akcja (http://localhost:53393/orders), która zwraca listę zamówień, a w której wykorzystałem właśnie opisywany mechanizm. Jak widać, w samym kontrolerze nie ma logiki mapowania enuma na string, wszystko robi za nas automapper, a kod samej akcji jest bardzo prosty:

public class OrdersController : Controller
{
private static IEnumerable<Order> _orders;
static OrdersController()
{
int orderNumber = 0;
_orders = new Faker<Order>()
.StrictMode(true)
.RuleFor(u => u.Status, (f, u) => f.PickRandom<OrderStatus>())
.RuleFor(u => u.Number, (f, u) => (++orderNumber).ToString())
.Generate(10);
}
private Lazy<IMapper> _mapper;
protected IMapper Mapper
{
get { return _mapper.Value; }
}
public OrdersController(Lazy<IMapper> mapper)
{
_mapper = mapper;
}
// GET: Orders
public ActionResult Index()
{
var viewModel = new IndexViewModel();
viewModel.Items = Mapper.Map<IEnumerable<OrderViewModel>>(_orders);
return View(viewModel);
}
}

Podsumowanie

Wyświetlanie enumów w interfejsie użytkownika nie musi być skomplikowane i czasochłonne. Wystarczy wykorzystać automapper, aby zrobił to za nas. Wykorzystując pliki resource’ów, otwieramy sobie możliwość tłumaczenia później aplikacji, co też jest istotne.

Jak podoba Ci się to rozwiązanie? Używasz czegoś podobnego? A może lepszego? Daj znać w komentarzu.

5 thoughts on “Lokalizacja Enum

  • Pingback: dotnetomaniak.pl
  • Niestety Twoje pomysły to próba wymyślania koła od nowa.
    1.Rozbijanie plików resource tylko dla kilku wartości zabija to co w Resource-ach jest najistotniejsze, czyli wydajność i prostotę. Tutaj polecam zapoznać się z tym czym właściwie są resource i jak działają m.in. jak działa w nich cache.
    2.Atrybut na wartościach enum-a są po to, żeby można je niezależnie konfigurować, ponieważ „słownik tłumaczeń” jest w zasadzie „słownikiem” i w ten sposób mamy pewność w kodzie, że dany klucz się nie powtarza, albo, że używamy danego klucza wielokrotnie świadomie.
    2.ToDisplayString przez to, że korzysta z refleksji jest strasznie niewydajne i w przypadku błędów dostajemy błąd z klasy z której jej nie oczekujemy. Nie mówiąc już o tym, że są tutaj błędy np. Twój atrybut można nadać wielokrotnie, a i tak weźmiemy pierwszą wartość.

    • Hej, dzięki za komentarz!

      A co masz na myśli o wymyślaniu koła od nowa? Chodzi Ci, że jest już coś gotowego, co można użyć? Kilka lat temu, kiedy potrzebowałem stworzyć taki automatyczny mechanizm nic niestety gotowego nie było.

      Oczywiście można też to zorganizować trochę inaczej. Sam kiedyś w niektórych aplikacjach używałem podobnego mechanizmu tylko, że miałem jeden plik resourców dla wszystkich enumów, z którego metoda ToDispalyString wyciągała wartość już bez użycia refekcji. Ale niestety takie podejście na dłuższą metę było problematyczne, w szczególności, gdy aplikacja była z budowana z modułów, które były dynamiczne ładowane z wykorzystaniem MEFa, gdzie klient mógł dodawać własne rozszerzenia do aplikacji. Więc nie dało się korzystać z jednego pliku resources dla enumów.

      A mógłbyś rozwinąć kwestię z punkut 1? Przyznam się szczerze, że jak kiedyś interesowałem się tematem, to właśnie ludzie zalecali, aby korzystać z wielu plików resource (np. https://stackoverflow.com/questions/8792180/1-resource-file-vs-many lub https://stackoverflow.com/questions/19910926/multiple-resource-files-versus-single-resource-file). U nas w projektach duże pliki resourców średnio się sprawdzały. Masz rację, że jeden plik będzie wydajniejszy ponieważ tylko raz go się wczytuje z dysku. A nie jest tak, że mechanizm cache spowoduje, że kolejne odczytanie wartości będzie już bez tego narzutu? W wolnej chwili z ciekawości sprawdzę jaki faktycznie jest narzut.

      Co do punktu 2 to myślę, że to zależy jak organizujesz sobie jako całoś korzystaniem z resourców. Jak pisałem w wpisie, sam kiedyś korzystałem z atrybutu Display na każdej wartości w enum, ale takie rozwiązaniem powodowało, że w definicji enuma było sporo zduplikowanego kodu, który w przypadku, gdy mam jeden plik resources per enum był nie do końca potrzebny. np.:

      enum OrderStatus
      {
      [Display(ResourceType = typeof(OrderStatusResources), Name = „Created”)]
      Created,
      [Display(ResourceType = typeof(OrderStatusResources), Name = „Processed”)]
      Processed
      }

      Masz rację, atrybuty na poziomie wartości są bardziej elastyczne, gdy na przykład korzystasz z jednego pliku resources, gdzie wartości są współdzielone przez różne elementy aplikacji.

      Natomiast co do metody ToDisplayString to masz rację, że w tym wydaniu nie jest efektywna. Dzięki Twojemu komentarzowy zauważyłem, że podczas edycji wpisu uciekł mi jeden akapit związany właśnie z tą kwestią. Pod koniec miesiąca planuje dodać wpis na temat BenchmarkDotNet, gdzie właśnie chciałem użyć tej metody jako przykładu do analizy wydajności.

      Jeszcze raz wielkie dzięki zakomentarz!

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.