15/05/2011

Aplikacje wielojęzyczne - ASP.NET

Home

Wstęp

Ostatnimi czasy zajmowałem się przygotowanie obcojęzycznej wersji systemu, na który składa się cała plejada aplikacji napisanych w różnych technologiach, od ASP.NET przez WinForms po WPF. Do tej pory zagadnienie to znałem przede wszystkim ze strony teoretycznej. To znaczy wiedziałem o różnych mechanizmach wbudowanych w .NET wspierających ten proces, nie omieszkałem ich wypróbować ale nie miałem okazji zastosować tej wiedzy do dużego i skomplikowanego systemu. Powiem więcej, proces lokalizacji aplikacji napisanej w .NET wydawał mi się relatywnie łatwy i prosty do przeprowadzenia, przecież platforma daje tyle za darmo. Teoria, teorią, a w praktyce okazało się, że nie jest to takie łatwe. Tym wstępem rozpoczynam serię postów, w której chcę się podzielić moimi doświadczeniami i przemyśleniami na ten temat.

Na początek ASP.NET. Przystępując do pisania tego postu zastanawiałem się czy zacząć od opisania podstaw związanych z przygotowaniem obcojęzycznej wersji aplikacji ASP.NET czy od razu przejść do opisania swoich doświadczeń. Zdecydowałem się na drugie podejście, trochę z lenistwa, a przede wszystkim dlatego, że nie lubię robić tego co zostało już zrobione, odsyłam na przykład do ASP.NET Globalization and Localization.

Stałe znakowe w kodzie

Zacznę od tego, że używanie w kodzie (mam tutaj na myśli kod C#, VB.NET, a nie markup) stałych znakowych powinno być karane zesłaniem na Sybir. Stałe zawierające komunikaty dla użytkownika itp. powinny znajdować się w zasobach aplikacji, a pozostałe, nazwijmy je techniczne powinny zostać zdefiniowany w jednym konkretnym miejscu np.:
public static class Constans
{
  ...
  public static const string Name = "Name";
  ...
}
Wróćmy jednak do treści wyświetlanych użytkownikom i załóżmy, że w kodzie znajdujemy cos takiego:
...
Msg.ShowMessage("Operation successful!");
...
Coś takiego jest złe, bardzo złe, niewyobrażalnie złe... Jeśli napiszemy coś takiego to potem osoba odpowiedzialna za przygotowanie obcojęzycznej wersji aplikacji (w tym przypadku ja) będzie musiał znaleźć w kodzie wszystkie miejsca tego rodzaju i przenieść komunikat do pliku z zasobami czyli wykonać naszą pracę. Takich miejsc może być bardzo dużo i ciężko jest znaleźć.

Użycie okienka Find, wyrażeń regularnych jest przydatne ale trzeba wiedzieć czego szukać, raz będzie to Msg.ShowMessage(...), innym razem lbl.Text = ..., a w jeszcze innym przypadku coś innego. Jeśli dodamy do tego wiele bibliotek to otrzymamy prawie syzyfowe zadanie. To oczywiście dotyczy aplikacji każdego rodzaju, a nie tylko ASP.NET. Powyższy kod powinien wyglądać tak jak poniżej (Msg to nazwa pliku z zasobami). Jeśli plik z zasobami zostanie przetłumaczony nie trzeba robić nic innego.
...
Msg.ShowMessage(Msg.OperationSuccessful);
...

Stałe znakowe w markup'ie

Przejdźmy teraz do kodu strony czyli tego co znajduje się w plikach *.aspx, *.ascx itd. Otóż myślałem, że z tym nie będzie problemu i planowałem użyć narzędzia dostarczanego razem z Visual Studio Tools->Generate Local Resources. Narzędzie to generuje dla strony/kontrolki plik zawierający "wszystkie" zasoby, które mogą podlegać lokalizacji i modyfikuje kod w ten sposób aby odpowiednia wersja zasobów była wczytywana automatycznie. Sądziłem, że będzie to bardzo proste, parę sekund na stronę i po zabawie. Praktyka wygląda tak:
  • Narzędzie to potrafi zamulić VS i to nawet dla stosunkowo prostych strony i na mocnej maszynie. Objawia się to tym, że środowisko przestaje odpowiadać na kilkadziesiąt sekund, a nawet dłużej.
  • Narzędzie to nie współpracuje ze wszystkimi kontrolkami i tak czy inaczej w niektórych przypadkach trzeba "ręcznie" tworzyć zasoby.
  • Jeśli nasza aplikacja składa się z wielu stron otrzymamy również wiele plików z zasobami. Liczbę tą należy jeszcze przemnożyć przez liczbę języków jakie chcemy obsługiwać. Uważam, że prowadzi to do bałaganu w projekcie. Osobiście wolę aby zasoby były zgromadzone w jednym, kilku plikach, a nie w kilkunastu lub kilkudziesięciu. Moim zdaniem ułatwia to zarządzanie nimi, tłumaczenie czy wprowadzanie poprawek.
  • Generate Local Resources wyniesienie do pliku z zasobem wszystko co się da. Może się zdarzyć, że dla danej strony musimy przetłumaczyć raptem kilka elementów natomiast plik z zasobami będzie ich zawierał wielokrotnie więcej.
Z tych względów postanowiłem zrezygnować z tego narzędzia i niestety samemu przeszukać kod stron i wynieść stałe znakowe do plików z zasobami. Aby odwołać się do tych zasobów z poziomu kodu strony wykorzystałem wyrażenia <% ... %>. Tutaj możemy wyróżnić dwa przypadki
Przypadek 1
<asp:Button ID="deleteButton" runat="server" Text="Usuń" />
można zamienić na:
<asp:Button ID="deleteButton" runat="server"  Text="<%$ Resources : lbl, Delete %>" />
lub na :
<asp:Button ID="deleteButton" runat="server"  Text="<%# Resources.lbl.Delete %>" />
W drugim wypadku trzeba pamiętać o wywołaniu metody DataBind. lbl to nazwa pliku z zasobami. Ja korzystałem głównie z pierwszej opcji.
Przypadek 2
<div>
  Nie znaleziono pliku
</div>
można zamienić na:
<div>
  <%= Resources.msg.FileNotFound %>
</div>
lub na:
<div>
  <asp:Literal runat="server" Text="<%$ Resources: msg, FileNotFound %>" />
</div>
lub na:
<div>
  <asp:Literal runat="server" Text="<%# Resources.msg.FileNotFound %>" />
</div>
lub na:
<div>
  <%# Resources.msg.FileNotFound %>
</div>
W dwóch ostatnich przypadkach trzeba pamiętać o wywołaniu DataBind. msg to nazwa pliku z zasobami. Ja korzystałem głównie z <%= ... %>, ma to jednak swoje ograniczenia. W pewnych, w cale nie rzadkich, przypadkach natrafimy na wyjątek The Controls collection cannot be modified because the control contains code blocks (i.e. % ... %). O tym jak sobie z tym poradzić i dlaczego tak jest pisałem w tym poście.

Inne

Tak jak napisałem na początku zajmowałem się przygotowaniem obcojęzycznej wersji dużego systemu. System ten ma swoją długą historię, a przejawem tego jest między innymi własny mechanizm lokalizowania. Mechanizm ten można było zastosować tylko do niektórych elementów interfejsu użytkownika. Co też zrobiłem bo skoro coś jest i działa to czemu z tego zrezygnować. Z perspektywy muszę przyznać, że nie była do dobra decyzja. Wykorzystanie więcej niż jednego mechanizmu lokalizowania aplikacji spowodowało, że konfiguracja tej aplikacji stała się trudniejsza. Jestem pewny, że za jakiś czas ktoś będzie się zastanawiał czemu tutaj widzę napisy po angielsku, a tutaj po polsku!?!?

Podsumowanie

W podsumowaniu powiem tylko jedną rzecz, którą zapewne powtórzę jeszcze nie raz:

Jeśli chcemy aby nasza aplikacja miała wiele wersji językowych to przygotowujmy się do tego od pierwszej linijki tej aplikacji.

Przez przygotowujmy się mam na myśli umieszczanie komunikatów w zasobach itp. ale również wypróbowanie dostępnych mechanizmów i sprawdzenie jak działają. Nie odkryłem tutaj Ameryki ale dopiero kiedy poczułem na własnej skórze co to znaczy lokalizacja dużej, starej aplikacji, która nie była do tego gotowa uświadomiłem sobie w pełni jak ważne jest myślenie o wielu wersjach językowych od samego początku.

08/05/2011

Float(1) = Float(24) ???

Home

Jakiś czas temu pracowałem nad komponentem, który odpowiadał za generacją tabel w bazie danych na podstawie zadanej definicji. Dodatkowo kod ten potrafił wykryć różnicę pomiędzy definicją, a faktyczną strukturą tabeli w bazie danych i poinformować o tym użytkownika. Przynajmniej w teorii bo w praktyce czasami twierdził, że definicja nie jest spójna ze stanem faktycznym w bazie danych chociaż na pierwszy rzut oka wyglądała, że jest.

Wspomniany kod zawierał oczywiście kilka błędów, które udało mi się szybko poprawić ale jeden zapadł mi w pamięci. Pewnie dlatego, że jego znalezienie było trochę trudniejsze. Błąd ten pojawiał się kiedy definicja tabeli zawierała kolumnę typu float. W takim wypadku komponent zawsze twierdził, że definicja i strukturą tabeli w bazie danych są inne.

Diagnozę błędu rozpocząłem od przygotowania definicji trywialnej tabeli z jedną kolumną typu float o zadanej precyzji 10. Komponent wygenerował tabelę przy użyciu poniższej komendy DDL i oczywiście stwierdził, że coś jest nie tak.
CREATE TABLE TestTable
(
 SomeNumber float(10) NULL
) 
Do wykrywaniu różnic pomiędzy definicją, a stanem bazy danych skorzystałem z widoków systemowych, a w szczególności z widoku sys.all_columns. Postanowiłem, więc zobaczyć jak wygląda wiersz odpowiadający kolumnie SomeNumber i otrzymałem taki wynik:

object_id name column_id system_type_id user_type_id max_length precision scale ...
2137058649 SomeNumber 1 59 59 4 24 0 ...

Moją uwagę od razu zwróciła zawartość kolumny precision równa 24. Zgodnie z definicja powinno być natomiast 10. Chwila konsternacji, szybkie spojrzenie do dokumentacji i wszystko okazało się jasne. SQL Server ze względu na zgodność ze standardem ISO pozwala napisać float(10), float(20) itp. ale wewnętrznie wartości od 1 do 24 traktuje jak 24, a wartości od 25 do 53 jako 53. Po uwzględnieniu tej informacji komponent zaczął działać prawidłowo.

03/05/2011

The Controls collection cannot be modified because the control contains code blocks (i.e. % ... %)

Home

The Controls collection cannot be modified because the control contains code blocks (i.e. <% ... %>). to paskudny błąd, który generowany jest przez klasę System.Web.UI.ControlCollection. Ma to miejsce przy próbie modyfikacji kolekcji, na przykład przez wywołanie Add(Control control), w przypadku kiedy kontrolki w niej zawarte korzystają z wyrażeń postaci <% ... %> lub <%= ... %>. Istnieje kilka sposób ominięcia tego problemu:
  • Kod zawierający wyrażenia <% ... %> lub <%= ... %> umieścić w dodatkowym bloku <div runat="server">...<div/>. Na przykład taki kod:
    <form id="form1" runat="server">
        <% for (int i = 0; i < 5; ++i) %>
        <% { %>
        <br />
        <asp:label runat="server" Text="Test" />
        <%= i.ToString() %>
        <% } %>
    </form>
    
    zamienimy na taki:
    <form id="form1" runat="server">
        <div runat="server">
        <% for (int i = 0; i < 5; ++i) %>
        <% { %>
        <br />
        <asp:label runat="server" Text="Test" />
        <%= i.ToString() %>
        <% } %>
        </div>
    </form>
    
    W drugim przypadku modyfikacja kolekcji (form1.Controls.Add(new Label());) nie spowoduje błędu ponieważ kontrolki form oraz div posiadają oddzielne kolekcje ControlCollection i tylko kolekcja kontrolki div jest w trybie tylko do odczytu.
  • Jeśli mamy dostęp do kontrolek Telerika to możemy skorzystać z RadCodeBlock.
Poniżej jeszcze dwa, mniej ogólne, rozwiązania problemu:
  • Zastąpienie <%= ... %> przez <%# ... %>. Na przykład taki kod:
    <%= Environment.MachineName %>
    
    możemy zastąpić takim:
    <%# Environment.MachineName %>
    
  • Użyć kontrolki Literal i taki kod:
    <%= Resources.lbl.Title %>
    
    zastąpić takim:
    <asp:Literal runat="server" Text="<%$ Resources: lbl, Title %>"/>
    
Rozwiązanie problemu to jedna sprawa. Mnie zaciekawiło dlaczego tak się dzieje. Poszukiwania rozpocząłem od przyjrzenia się implementacji kolekcji ControlCollection. Tutaj jak zwykle pomocny okazał się .NET Reflector. Na początki dowiedziałem się, że kolekcja ma prywatne pole _readOnlyErrorMsg; . Jeśli jest ono różne od null i nastąpi próba modyfikacji kolekcji to generowany jest wyjątek:
...
if (this._readOnlyErrorMsg != null)
{
  throw new HttpException(SR.GetString(this._readOnlyErrorMsg));
}
...
Pole to ustawiane jest tylko w jednym miejscu, w metodzie ControlCollection.SetCollectionReadOnly(string errorMsg). Metoda ta wywoływana jest w kilku miejscach, ale tylko w jednym przypadku parametr errorMsg odpowiada komunikatowi The Controls collection cannot be modified.... Ma to miejsce w metodzie Control.SetRenderMethodDelegate(RenderMethod renderMethod). I tu pojawił się problem. Okazało się, że ta metoda nie jest nigdzie wywoływana, a przynajmniej tak twierdził .NET Reflector (i w sumie się nie pomylił).

Wywołanie tej metody znalazłem w plikach generowanych dynamicznie dla stron aspx. Znajdziemy je w katalogu z plikami tymczasowymi C:\WINDOWS\Microsoft.NET\Framework\v4.0.30319\Temporary ASP.NET Files\.... Dokładej lokalizacji nie podaję ponieważ nazwy tych plików generowane są w sposób losowy. Dla przykładu z początku postu (z pętlą for) zostanie wygenerowany taki kod:
...
@__ctrl.SetRenderMethodDelegate(new System.Web.UI.RenderMethod(this.@__Renderform1));
...
Metoda @__Renderform1 wygląda natomiast tak (po usunięciu dyrektyw kompilatora):
...
private void @__Renderform1(System.Web.UI.HtmlTextWriter @__w, System.Web.UI.Control parameterContainer) 
{
  @__w.Write("\r\n    \r\n        ");
  for (int i = 0; i < 5; ++i) 
  { 
    @__w.Write("\r\n    <br />\r\n    ");
    parameterContainer.Controls[0].RenderControl(@__w);
    @__w.Write("\r\n    ");
    @__w.Write( i.ToString() );
    @__w.Write("\r\n    ");
  }
}
...
Co w tym takiego zdrożnego, że silnik ASP.NET nie pozwala na modyfikowanie kolekcji? Moim zdaniem kluczowa jest linijka zawierająca odwołanie to kolekcji Controls. Jak widać indeks kontrolki do jakiej się odwołujemy jest ustawiony na sztywno w kodzie. W takim przypadku modyfikowanie kolekcji kontrolek byłoby albo bezcelowe bo i tak kontrolka nie została by wyrenderowana albo doprowadziło to wyrenderowania innej kontrolki niż trzeba.

Ciekawe posty na ten temat można znaleźć również na blogu Rick Strahl's Web Blog. Podaje tylko tytuły artykułów, a nie linki ponieważ w momencie pisania tego postu artykuły te były dostępne tylko w cache Google'a:
  • Reliably adding Controls or Markup before or after an ASP.NET Control?
  • Understanding how <% %> expressions render and why Controls.Add() doesn't work

26/04/2011

Problem z magazynem certyfikatów

Home

Jakiś czas temu pracowałem nad aplikacją WWW, która między innymi zajmowała się wysyłaniem SMS'ów. SMS'y wysyłałem przy pomocy Web Service'u udostępnionego przez operatora. Aby wysłać taki SMS musiałem uwierzytelnić się korzystając z infrastruktury klucza publicznego - dokładniej mówiąc aby wysłać wiadomość musiałem przedstawić certyfikat podpisany przez operatora. Jak to zazwyczaj bywa testy przy użyciu serwera WWW wbudowanego w Visual Studio powiodły się. Niestety po zainstalowaniu aplikacji na serwerze IIS 7.0 aplikacja nie potrafiła znaleźć certyfikatu w magazynie kluczy i próba wysłania SMS'a kończyła się wyjątkiem:

System.Security.Cryptography.CryptographicException: Zestaw kluczy nie istnieje.

Początkowo myślałem, że to błąd konfiguracji czyli, że szukam certyfikatu o błędnej nazwie. Szybko okazało się jednak, że konfiguracja jest poprawna. W tym momencie przypomniałem sobie o kwestii uprawnień. Przecież tożsamość puli aplikacyjnej w jakiej działa aplikacja musi mieć odpowiednie uprawnienia aby uzyskać dostęp do magazynu certyfikatów.

Problem rozwiązałem bardzo szybko zmieniając tożsamość puli aplikacyjnej na System Lokalny (Local System). Rozwiązanie to nie spodobało mi się jednak ponieważ w ten sposób pula aplikacyjna dostała dużo, dużo więcej uprawnień niż tylko prawo dostępu do magazynu certyfikatów. Krótkie poszukiwania naprowadziły mnie na poprawnego rozwiązanie. Otóż należy nadać puli aplikacyjnej (tożsamości puli aplikacyjnej) uprawnienia TYLKO do wybranych certyfikatów w magazynie.

W tym celu uruchamiamy konsolę zarządzania (mmc.exe) i dodajemy do niej przystawkę zarządzania certyfikatami (pisałem o tym w poście Błąd przy dodawaniu przystawki do konsoli zarządzania). Następnie w kategorii Osobisty (My) znajdujemy interesujący nas certyfikat i z menu kontekstowego wybieramy Wszystkie zadania -> Zarządzaj kluczami prywatnymi.... W okienku jakie się pojawi nadajemy użytkownikowi z jakiego korzysta pula prawa do odczytu. Zalecane jest konto wbudowane Usługa sieciowa (Network Service).

17/04/2011

Błąd przy dodawaniu przystawki do konsoli zarządzania

Home

Przystawka certyfikatów (ang. Certificates Snap-in) pozwala na przeglądanie magazynu certyfikatów dla użytkownika, usługi czy też komputera. Aby uruchomić to narzędzie wystarczy w wierszu poleceń lub do okienka Uruchom wpisać polecenie certmgr.msc. Problem polega na tym, że tak uruchomiona przystawka certyfikatów pokaże nam tylko magazyn dla bieżącego użytkownika.

Jeśli chcemy zobaczyć magazyn certyfikatów komputera, tak było w moim przypadku, czeka nas trochę więcej pracy. Zaczynamy od uruchomienia konsoli zarządzania (ang. Microsoft Management Console) wpisując w wierszu poleceń lub do okienka Uruchom komendę mmc.exe. Następnie wybieramy Plik -> Dodaj/Usuń przystawkę. Przystawka certyfikatów znajduje się na początku listy i po jej wybraniu zostaniemy poproszeni o zaznaczenie jakimi certyfikatami chcemy zarządzać: użytkownika, usługi czy komputera. Na koniec klikamy Ok.

Tak to wygląda w teorii, w praktyce po naciśnięciu przycisku Ok konsola zarządzania raportowała błąd (pokazany poniżej) i kończyła pracę, bez względu na to jaka przystawkę wybrałem.
  Nazwa zdarzenia problemu: APPCRASH
  Nazwa aplikacji: mmc.exe
  Wersja aplikacji: 6.0.6002.18005
  Sygnatura czasowa aplikacji: 49e02760
  Nazwa modułu z błędem: StackHash_7ae8
  Wersja modułu z błędem: 6.0.6002.18327
  Sygnatura czasowa modułu z błędem: 4cb74dd3
  Kod wyjątku: c0000374
  Przesunięcie wyjątku: 00000000000aca57
  Wersja systemu operacyjnego: 6.0.6002.2.2.0.256.6
  Identyfikator ustawień regionalnych: 1045
  Dodatkowe informacje 1: 7ae8
  Dodatkowe informacje 2: fab1f7793b8a08e05290bb8ef1ca5c9e
  Dodatkowe informacje 3: 1607
  Dodatkowe informacje 4: 3b4ea5c6cc4724ebe1b8e0ae80fae1cf
Pan Google nie był zbyt pomocny. Radził aby zainstalować SP2 dla Visty, który już mam zainstalowany. Na innej stronie ktoś twierdził, że problem pojawia się jeśli na jednej maszynie zainstalowane są dwie wersje MSSQL i że trzeba jedną z nich odinstalować. Ja mam akurat zainstalowane dwie wersje MSSQL ale nie miałem najmniejszej ochoty usuwać z dysku żadnej z nich. Trzecia osoba radziła aby na komputerze o zbliżonej do problematycznego konfiguracji, na którym mmc.exe działa, wyeksportować klucze rejestru dotyczące konsoli i zaimportować je na komputerze, na którym występuje problem. Ta rada też nie przypadła mi do gustu ponieważ nie miałem takiego komputera pod ręką. Nie wiedziałem także, na ile ta konfiguracja powinna być "zbliżona" aby było dobrze.

Postanowiłem jednak pójść tropem zawartości rejestru i konfliktu pomiędzy różnymi wersjami MSSQL. Na początek zauważyłem, że na liście dostępnych przystawek znajdują się dwie przystawki o takiej samej nazwie SQL Server Configuration Manager. Zapewne dedykowane dla różnych wersji MSSQL. Następnie postanowiłem zajrzeć do rejestru do klucza, który przechowuje listę wszystkich dostępnych przystawek:

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\MMC\SnapIns\

Jest ich tam kilkadziesiąt i mają niewiele mówiące nazwy np.: d52e5f54-75d9-4a93-91b7-2215ea5cbed2 ale szybko udało mi się znaleźć klucze odpowiadające przystawce SQL Server Configuration Manager. Pomyślałem sobie "raz kozie śmierć", zobaczę co się stanie jak usunę jeden z nich. Oczywiście najpierw wyeksportowałem kopię klucza, a dopiero potem go usunąłem. Okazało się to strzałem w dziesiątkę. Po tej operacji dodanie nowej przystawki w końcu zadziałało. Co ciekawe nie ma znaczenia czy usuniemy klucz przystawki dla MSSQL 2005, czy dla MSSQL 2008.

Reasumując. Jeśli operacja dodania nowej przystawki konsoli zarządzania kończy sie błędem, a masz na komputerze zainstalowane dwie (lub więcej) wersji MSSQL to z dużym prawdopodobieństwem problem spowodowany jest konfliktem pomiędzy przystawkami SQL Server Configuration Manager dla różnych wersji MSSQL. Można go rozwiązać usuwając odpowiedni wpis z rejestru. Nie jest to idealne rozwiązanie, ale z braku laku dobry kit.