Showing posts with label .NET. Show all posts
Showing posts with label .NET. Show all posts

12/02/2012

Problem z AssemblyResolve

Home

Niedawno miałem okazję pracować z aplikacją, w której zaimplementowano obsługę zdarzenia AppDomain.AssemblyResolve. Zdarzenie to pozwala załadować assembly jeśli standardowy mechanizm platformy .NET nie poradzi sobie z tym zadaniem. Aplikacja działała poprawnie aż do migracji na platformę .NET 4.0 Czemu? O tym właśnie będzie post. Zwrócę w nim uwagę na dość istotną różnicę pomiędzy platformą .NET 4.0, a jej wcześniejszymi wersjami jeśli chodzi o wspomniane zdarzenie. Różnica ta, w określonych warunkach, może napsuć krwi.

Przechodząc do meritum chodzi o to, że począwszy od .NET 4.0 zdarzenie AppDomain.AssemblyResolve generowane jest dla wszystkich assemblies, również tych z zasobami. Poniżej cytat z dokumentacji z MSDN'a.

Beginning with the .NET Framework 4, the ResolveEventHandler event is raised for all assemblies, including resource assemblies. In earlier versions, the event was not raised for resource assemblies. If the operating system is localized, the handler might be called multiple times: once for each culture in the fallback chain.

Oznacza to, że jeśli biblioteka SomeLibrary zawiera zasoby, to zdarzenie AppDomain.AssemblyResolve zostanie wygenerowane dla nie dwa razy. Raz z żądaniem załadowania assembly o nazwie SomeLibrary, a drugi raz z żądaniem załadowania SomeLibrary.resources. Fizycznie oba assembly znajdują się natomiast w dll'ce o nazwie SomeLibrary.dll.

Jeśli obsługa zdarzenia AppDomain.AssemblyResolve zaimplementowana została w uproszczony albo raczej błędy sposób mamy problem. Mam tu na myśli implementację, w której nie przewidziano, że nie uda się znaleźć dll'ki o nazwie zgodnej z nazwą assembly. W omawianym przypadku wyglądało to mniej więcej tak:
private static Assembly MyResolveEventHandler(object sender, ResolveEventArgs args)
{
 //Determine assembly path based on the configuration
 //...
 
 Assembly asm = Assembly.LoadFrom(path);
 return asm;
}
Łatwo się domyślić, że wywołanie Assembly.LoadFrom(path) dla ścieżki SOME_PATH\SomeLibrary.resources.dll zakończy się błędem bo taka ścieżka nie istnieje. W aplikacji objawi się to wyjątkiem FileNotFoundException przy próbie odczytania czegokolwiek z zasobów. Powinno to zostać zrobione wcześniej ale łatwo to naprawić:
private static Assembly MyResolveEventHandler(object sender, ResolveEventArgs args)
{
 //Determine assembly path based on the configuration
 //...
 
 try
 {
  return Assembly.LoadFrom(path);
 }
 catch(Exception ex)
 {
  //Log exception
  
  return null;
 }
}
Albo w taki sposób, jeśli chcemy obsłużyć tylko scenariusz z zasobami:
private static Assembly MyResolveEventHandler(object sender, ResolveEventArgs args)
{
 //Determine assembly path based on the configuration
 //...
 
 if(args.Name.Contains(".resources,"))
  return null;
  
 Assembly asm = Assembly.LoadFrom(path);
 return asm;
}
Zwrócenie null powoduje, że zostanie użyty standardowy mechanizm wyszukiwania i ładowania assemblies platformy .NET, a ponieważ dll'ka SomeLibrary.dll zostanie załadowana przy pierwszym żądaniu to poradzi sobie on z załadowaniem assembly z zasobami.

07/02/2012

RavenDB (cz. 3) - zapytania

Home

Wstęp

W tej części serii poświęconej Raven DB napiszę o zapytaniach, pobieraniu danych. Temat sam w sobie jest bardzo obszerny i to, co napiszę, to tylko szczyt góry lodowej. Większość tematów po prostu zasygnalizuje, ale sądzę, że dobrze pokaże, jak to wygląda z Raven DB.

Podstawy

Podstawowe zapytania zadajemy w bardzo prosty sposób i robimy to otwierając najpierw sesję pracy z bazą np.:
using (var session = Store.OpenSession())
{
 var res = from ex in session.Query<ExpressionEntity>()
    select ex;
 ...
}
Aby pobrać listę dokumentów użyłem metody Query określając jaki typ dokumentów mnie interesuje. Dla przypomnienia klasa ExpressionEntity reprezentuje wyrażenie i jego tłumaczenia. Jest to ta sama klasa, która wcześniej posłużyła mi do umieszczenia danych w bazie.

Jeszcze jeden przykład. Tym razem pobieram listę dokumentów by na jej podstawie określić listę różnych kategorii, jakie zdefiniowano dla wszystkich wyrażeń.
var res = (from e in session.Query<ExpressionEntity>()
    where e.Category != null
    orderby e.Category
    select e.Category).Distinct();
Jak widać podstawowe zapytania wykonuje się bardzo łatwo.

Stronicowanie

Przy dużej liczbie dokumentów przydatne okaże się stronicowanie. To też nie jest trudne do zrealizowania:
var res = (from ex in session.Query<ExpressionEntity>()
    orderby ex.Expression
    select ex).Skip(index * pageSize).Take(pageSize).ToList();
Użyta w kodzie zmienna index to indeks strony do pobrania (w numerowaniu od zera), a pageSize to oczywiście wielkość strony liczona w liczbie dokumentów. Metoda Skip pozwala więc na przeskoczenie do konkretnej strony, a metoda Take na pobranie takiej liczby dokumentów jaka mieści się na stronie.

Robi się trudniej

Jedną z funkcjonalności, jakie chciałem mieć w swoim programie LanguageTrainer, było zliczanie liczby wyrażeń posiadających tłumaczenie w danym języku. Brzmi prosto, prawda? Pierwsza moja próba wyglądała tak:
 var count = 
 (from ex in session.Query<ExpressionEntity>()
 from t in ex.Translations
 where t.Language == selectedLang && !String.IsNullOrEmpty(t.Translation) 
 select 1).Count();
Zmienna selectedLang zawiera interesujący nas język. Wykonanie takiego zapytania zakończy się wyjątkiem NotSupportedException z komunikatem Method not supported: SelectMany. A więc może coś takiego:
var count =
 (from ex in session.Query<ExpressionEntity>()
 where ex.Translations.Any(t => t.Language == selectedLang && !String.IsNullOrEmpty(t.Translation))
 select 1).Count();
Tym razem zakończy się wyjątkiem z komunikatem Method not supported: IsNullOrEmpty. No cóż IsNullOrEmpty łatwo zastapić zwykłym porównaniem. Spróbujmy więc jeszcze raz:
var count = 
 (from ex in session.Query<ExpressionEntity>()
 where ex.Translations.Any(t => t.Language == selectedLang && t.Translation != null && t.Translation != String.Empty)
 select 1).Count();
To też nie zadziała i znowu zakończy sie błędem, tym razem z komunikatem Node not supported: Constant. Jeszcze jednak próba i w końcu zadziałało:
var count = 
 (from ex in session.Query<ExpressionEntity>()
 where ex.Translations.Any(t => t.Language == selectedLang && t.Translation != null && t.Translation != String.Empty)
 select ex).Count();
Nie jest to skomplikowane ale wymaga znajomości kilku "trików", nie jest do końca intuicyjne.

MapReduce

Do opisanego powyżej problemu można też podejście w Raven DB w inny sposób, a mianowicie stosując algorytm MapReduce. W ten sposób wykonując jedno zapytanie otrzymamy wyniki dla wszystkich języków za jednym razem. W Raven DB robimy to definiując indeks (jeszcze o tym napiszę):
public class TranslationsCounter : AbstractIndexCreationTask<ExpressionEntity, TranslationsCounter.ReduceResult>
{
 public class ReduceResult
 {
  public Languages Lang { get; set; }
  public int Count { get; set; }
 }

 public TranslationsCounter()
 {
  Map = docs => from doc in docs
   from t in doc.Translations
   select new { Lang = t.Language, Count = String.IsNullOrEmpty(t.Translation) ? 0 : 1 };

  Reduce = results => from t in results
   group t by t.Lang
   into g
   select new { Lang = g.Key, Count = g.Sum(x => x.Count) };
 }
}
i zadanie zapytania przy jego użyciu np.:
public IDictionary<Languages,int> CountExpressionsByLanguage()
{
 using (var session = Store.OpenSession())
 {
  var dict = new Dictionary<Languages, int>();

  foreach(var res in session.Query<TranslationsCounter.ReduceResult, TranslationsCounter>())
  {
   dict.Add(res.Lang, res.Count);
  }

  return dict;
 }
}


To jeszcze nie koniec. Do tematu wrócę w kolejnym poście.

22/01/2012

RavenDB (cz. 2) - podstawowe operacje na dokumentach

Home

Wstęp

W drugim poście poświęconym Raven DB opiszę jak wykonywać podstawowe operacje (zapisz/usuń/zmień) na dokumentach. Ale czym jest dokument? Nie wiem jaka jest formalna definicja ale ja na dokumenty patrzę po prostu jak na obiekty zapisane (zserializowane) w formacie JSON. Poniżej przykład takiego dokumentu z mojego pet project, który odpowiada obiektowi klasy reprezentującej wyrażenie i jego tłumaczenia.
{
  "Category": "Geografia",
  "Expression": "wioska",
  "Translations": [
    {
      "Language": "Language1",
      "Translation": "Dorf",
      "GoodAnswers": 0,
      "BadAnswers": 1,
      "WasLastAnswerGood": false
    },
    {
      "Language": "Language2",
      "Translation": "village",
      "GoodAnswers": 1,
      "BadAnswers": 0,
      "WasLastAnswerGood": true
    }
  ]
}
Klasa, której obiekty chcemy wrzucić do Raven DB może być zwykłym POCO z dokładnością do jednej rzeczy. Musi posiadać taką właściwość:
public int Id { get; set; }
To przykład konwencji. Jeśli chcemy zmienić sposób przechowywania identyfikatora, należy skorzystać z DocumentStore.Conventions.FindIdentityProperty. Użyty przeze mnie EmbeddableDocumentStore dziedziczy z DocumentStore, a więc jeśli znudzi mi się Id to zawsze mogę to zmienić.

Jeśli chcemy aby Raven DB ignorował niektóre właściwości w czasie zapisywania obiektu to powinniśmy oznaczyć je atrybutem Newtonsoft.Json.JsonIgnore, który działa w taki sam sposób jak XmlIgnore dla XmlSerializer.

Zapisz/Zmień

Operację wstawienia nowego dokumentu do bazy lub aktualizacji już istniejącego wykonuję w taki sam prosty sposób np.:
public void Save(ExpressionEntity ex)
{
 using (var session = Store.OpenSession())
 {
  session.Store(ex);
  session.SaveChanges();
 }
}
Zaczynam, więc od otworzenia sesji pracy z RavenDB. Następnie przy pomocy metody Store wrzucam do bazy nowy dokument lub aktualizuję już istniejący. Jak widać nie przejmuję się, którą operację właściwie wykonuję. Jeśli obiekt będzie miał pusty/zerowy Id to zostanie utworzony nowy dokument, a właściwość Id zostanie uaktualniona. Jeśli Id zostanie wypełnione Raven DB spróbuje odszukać istniejący dokument i go uaktualnić. Jeśli nie znajdzie dokumentu o podanym identyfikatorze utworzy go. Metoda SaveChanges to coś w rodzaju Commit'a: wysyła zmiany do serwera.

Usuń

Usuwanie dokumentów z bazy również jest bardzo proste np.:
public void Remove(ExpressionEntity ex)
{
 using (var session = Store.OpenSession())
 {
  session.Delete<ExpressionEntity>(session.Load<ExpressionEntity>(ex.Id));
  session.SaveChanges();
 }
}
Jak widać przed usunięciem ładuję obiekt z bazy na podstawie identyfikatora. Jest to potrzebne ponieważ inaczej dostaniemy błąd z informacją, że obiekt nie jest doczepiony do sesji (is not associated with the session, cannot delete unknown entity instance). Nie znalazłem innego sposobu rozwiązania problemu.

Ważna uwaga

Powyżej przedstawiłem dwie przykładowe metody Save oraz Remove, które odpowiednio tworzą/aktualizują jeden dokument oraz usuwają jeden dokument z bazy danych. W przypadku wykonywania operacji na wielu dokumentach takie podejście będzie zbyt kosztowne. Jeśli zachodzi potrzeba pracy z wieloma dokumentami to powinniśmy zrezygnować z takiego mikro zarządzania i jak najwięcej operacji wykonywać w ramach jednej sesji.

Podsumowanie

Dzisiaj pokazałem jak wykonywać podstawowe operacje na dokumentach. Jak widać jest to bardzo proste. Właściwie nie musimy nic robić aby nakłonić nasze obiekty do współpracy z Raven DB. Nie potrzebujemy również mappera relacyjno-obiektowego. To jest właśnie to co bardzo mi się spodobało w Raven DB.

Definiuję sobie klasę, tworzę jej instancje i po prostu wrzucam je do bazy danych. Nie piszę komend INSERT, UPDATE lub DELETE. Nie instaluję dodatkowych komponentów. Nie konfiguruję mapowań pomiędzy tabelami i obiektami. Nie definiuję schematu bazy danych. Jest prosto, szybko i przyjemnie czyli tak jak powinno być!

Na koniec znowu podsumujmy co już mamy/umiemy:
  • Bazę Raven DB osadzoną w aplikacji hostującej.
  • Kod inicjalizujący Raven DB.
  • Dostęp do Raven Studio i API REST'owego.
  • Obiekty POCO jakie mogą zostać umieszczone w Raven DB.
  • Kod dodający/zmieniający/usuwający dokumenty.

19/01/2012

RavenDB (cz. 1)

Home

Raven DB to dokumentowa baza danych, przedstawiciel trendu NoSQL, opracowana przez Ayende i jego zespół. Całkiem niedawno bo w październiku 2011 Ayende był zresztą w Polsce i opowiadał o swoim dziecku. Kilka miesięcy temu w jednym ze swoich pet project postanowiłem zmienić technologię dostępu do danych i wybór padł właśnie na Raven DB.

Kilka słów wstępu

Projekt ten to program LanguageTrainer wspomagający naukę słówek. Pomysł jego napisania narodził się ponad rok temu kiedy "ponownie" rozpocząłem naukę języka niemieckiego. W sieci nie znalazłem programu, który spełniałby moje oczekiwania. Zdecydowałem więc, że napiszę coś szytego na miarę. Początkowo rozpoczęło się niewinnie: listę słówek i ich tłumaczeń trzymałem w pliku. Dokładniej mówiąc był to po prostu wynik serializacji XML'owej. Podejście to przestało się sprawdzać kiedy do głowy zaczęły mi przychodzić kolejne pomysły: A może dodać bardziej zaawansowane wyszukiwanie, jakieś statystyki itd.

Początkowo pomyślałem o przejściu na bazę relacyjną, ale stwierdziłem, że tutaj nie nauczę się niczego nowego. Pomyślałem, że fajnie będzie wypróbować coś zupełnie nowego, z czym nie miałem jeszcze do czynienia, a ponieważ wcześniej czytałem trochę o Raven DB i wiedziałem, że ma .NET'owe API, wybrałem właśnie ją.

Z bazą tą już trochę pracuję, z braku czasu niezbyt intensywnie, ale zebrałem już trochę doświadczeń i postanowiłem sie nimi podzielić. Planuję serię postów, w których opiszę jak pracuje się z Raven DB na przykładzie swojego pet project. Nie będzie to jednak typowy tutorial omawiający wszystkie zagadnienia. Poruszę tylko te, z którymi miałem okazję się zapoznać.

Zaczynamy

LanguageTrainer to typowa aplikacja grubego klienta (WPF + MVVM). Nie chciałem jednak uzależniać się od połączenia z serwerem. Dlatego zdecydowałem się na hostowanie Raven DB w procesie aplikacji. Po ściągnięciu paczki z binariami na swoje potrzeby skopiowałem więc katalog EmbeddedClient. W tym momencie zaznaczę jeszcze, że warstwę dostępu do danych mam ukrytą za dobrze zdefiniowanym interfejsem, a więc aplikacja nie wie z jakim źródłem danych pracuje. Poniżej fragment kodu z klasy implementującej ten interfejs, odpowiedzialny za zainicjowanie Raven DB.

public EmbeddableDocumentStore Store { get; private set; }

...

public void Init(string dir)
{
 Store = new EmbeddableDocumentStore
 {
  DataDirectory = dir,
  UseEmbeddedHttpServer = true,
 };

 Store.Initialize();
}
 


Instancja EmbeddableDocumentStore posłuży nam później do otwierania sesji pracy z Raven DB, wykonywania zapytań itd. W kodzie tym ustawiam tylko dwie właściwości. DataDirectory wskazuje katalog roboczy, w którym Raven DB utworzy odpowiednią strukturę katalogów i gdzie będzie trzymał dane. Za pierwszym razem inicjalizacja zajmie więc trochę więcej czasu.

Istotna jest też właściwość UseEmbeddedHttpServer. Dzięki ustawieniu jej na true mam dostęp do napisanej w Silverlight aplikacji Raven Studio do zarządzania dokumentami oraz, co mniej ważne z mojej perspektywy, dostęp REST'owy do dokumentów. Oczywiście ponieważ Raven DB jest hostowany przez mój program, aplikacja Raven Studio będzie dostępna tylko wtedy, kiedy uruchomiony jest LanguageTrainer.

Numer portu z jakiego będzie korzystał serwer HTTP ustawiamy w pliku konfiguracyjnym aplikacji dodając następujący wpis:
<appSettings>
    <add key="Raven/Port" value="8888" />
</appSettings>

To jednak nie wszystko. Jeśli w tym momencie spróbujemy uruchomić Raven Studio otrzymamy od serwera HTTP taki błąd:

Could not find file Raven.Studio.xap, which contains the Raven DB Studio functionality. Please copy the Raven.Studio.xap file to the base directory of RavenDB and try again.

Plik Raven.Studio.xap znajdziemy w dystrybucji Raven DB i musimy umieścić go w katalogu z jakiego uruchamiamy aplikację. Ja zrobiłem to w ten sposób, że dodałem go do projektu, Build Action ustawiłem na Content, a Copy to Output Directory na Copy if newer. Testowałem w przeglądarkach Chrome, Firefox oraz IE i działa.

Podsumowanie

Tyle na dzisiaj. Podsumujmy co już mamy/umiemy:
  • Bazę Raven DB osadzoną w aplikacji hostującej.
  • Kod inicjalizujący Raven DB.
  • Dostęp do Raven Studio i API REST'owego.
W następnej części opiszę jak wykonywać podstawowe operacji na dokumentach.

01/12/2011

Wczytywanie podzespołów do domeny aplikacyjnej

Home

Platforma .NET, dzięki mechanizmowi refleksji, pozwala na dynamiczne wczytywanie do programu podzespołów (ang. assembly). Pozwala to w łatwy sposób pisać rozszerzane przy pomocy pluginów aplikacje i na wiele innych rzeczy. Ostatnio potrzebowałem wykorzystać ten mechanizm do własnych celów. Aby zwiększyć bezpieczeństwo, postanowiłem ładować podzespoły do odzielnych domen aplikacyjnych. W ten sposób, jeśli po załadowaniu podzespołu i wykonaniu jego kodu pojawi się błąd, główna domena aplikacyjna pozostaje nienaruszona.

Użycie osobnej domeny aplikacyjnej przydaje się również kiedy chcemy wczytywać i usuwać załadowane assembly z pamięci. Problem polega na tym, że po załadowaniu podzespołu do domeny nie ma możliwości aby go z niej usunąć. Można jednak osiągnąć podobny rezultat ładując podzespoły do oddzielnych "roboczych" domen, a potem skorzystać z metody AppDomain.Unload, która usuwa z pamięci domenę i wszystkie wczytane do niej podzespoły. (To pewne uproszczenie. Jeśli assembly zostało załadowane do kilku domen to zostanie usunięte dopiero jeśli usuniemy wszystkie domeny ją używające.)

Jak to zrobić? W sieci można znaleźć kilka podejść, ja użyłem w uproszczeniu następującego sposobu:
public static class SeperateDomainAssemblyLoader
{
  [Serializable]
  private class InternalLoader
  {
    public void LoadAndProcess(string assemblyPath)
    {
      Console.WriteLine(AppDomain.CurrentDomain.FriendlyName);

      var assembly = Assembly.LoadFrom(assemblyPath);
      //...
    }
  }

  private static AppDomain _domain = AppDomain.CreateDomain("SeperateDomainAssemblyLoader");

  public static void LoadAndProcess(string assemblyPath)
  {
    InternalLoader internalLoader = (InternalLoader)(_domain.CreateInstanceFromAndUnwrap(Assembly.GetExecutingAssembly().Location, typeof(InternalLoader).FullName));
    internalLoader.LoadAndProcess(assemblyPath);
   }
}
i kod testujący:
...
Console.WriteLine(AppDomain.CurrentDomain.FriendlyName);
SeperateDomainAssemblyLoader.LoadAndProcess(somePath);
...
Niestety ku mojemu zdziwieniu program wypisał na ekran dwa razy tą samą nazwę domeny. Jak to możliwe, przecież jak wół stoi, że instancja klasy InternalLoader została stworzona w osobnej domenie. Uważni czytelnicy już pewnie widzą błąd. Ja też go znalazłem, ale chwilę zajęło mi uzmysłowienie sobie, co robię nie tak.

Zapomniałem o tym, że obiekty pomiędzy domenami aplikacyjnymi przekazywana są domyślnie przez wartość. Co z tego, że utworzyłem obiekt w osobnej domenie, skoro i tak pracowałem z jego kopią. Jeśli InternalLoader dziedziczyłby z MarshalByRefObject to pracowałabym nie z prawdziwym obiektem ale z proxy i wszystko byłoby dobrze. Poprawka jest więc bardzo prosta:
...
private class InternalLoader : MarshalByRefObject
{
  ...
}
...

28/10/2011

Wiele usług w jednym procesie 2

Home

W poście tym wrócę jeszcze do tematu uruchamiania kilku usług w jednym procesie. Otóż, ciekawe jest to, że można konfigurować to zachowanie już po zainstalowaniu usługi. Służy do tego, i nie tylko tego, program wiersza poleceń o nazwie sc. Poniżej przedstawiam przykład jego użycia.

Zacznijmy od pobrania konfiguracji usługi ABC przy pomocy komendy sc query ABC. Przykładowy wynik pokazałem poniżej.
SERVICE_NAME: ABC
TYPE               : 20  WIN32_SHARE_PROCESS
STATE              : 1  STOPPED
  (NOT_STOPPABLE,NOT_PAUSABLE,IGNORES_SHUTDOWN)
WIN32_EXIT_CODE    : 1077       (0x435)
SERVICE_EXIT_CODE  : 0  (0x0)
CHECKPOINT         : 0x0
WAIT_HINT          : 0x0
Jak widać usługa ABC może współdzielić proces z jakąś inną usługą. Efekt działania bardzo ładnie widać w menadżerze zadań. Jeśli uruchomimy usługi: ABC oraz ABCDebug to menadżer zadań pokaże tylko jeden proces.



Aby to zmienić należy użyć komendy sc config ABC type= own. Teraz, po uruchomieniu obu usług, menadżer zadań pokaże dwa procesy.



Do stanu pierwotnego wracamy przy pomocy komendy sc config ABC type= share. Warto zwrócić uwagę na jeszcze jedną rzecz. Spójrzmy na ten scenariusz.
  • Mamy dwie usługi współdzielące proces: ABC oraz ABCDebug..
  • Uruchamiamy obie i menadżer zadań pokazuje jeden nowy proces: WindowsService1.exe.
  • Zatrzymujemy usługę ABC.
  • Usługa ABCDebug nadal działa.
  • sc config ABC type= own
  • Uruchamiamy usługę ABC i teraz menadżer zadań pokazuje dwa procesy WindowsService1.exe.
  • Zatrzymujemy usługę ABC
  • sc config ABC type= share
  • Ponownie uruchamiamy usługę ABC i menadżer zadań znowu pokazuje jeden proces WindowsService1.exe.
Czyli w czasie kiedy zmienialiśmy konfigurację usługi ABC usługa ABCDebug cały czas działała i w niczym to nie przeszkadzało.

26/10/2011

Wiele usług w jednym procesie

Home

Istnieje kilka podejść do debugowania usług systemowych. Jeśli chcemy debugować już uruchomioną usługę to możemy skorzystać z opcji Attach to process.... Sprawa jest trudniejsza jeśli chcemy podłączyć się do usługi w momencie jej uruchamiania. W takim wypadku można w kodzie usługi wywołać metodę Debugger.Brake. Są też inne sposoby, na przykład sztuczne opóźnienie startu usługi, tak aby zdążyć się do niej podpiąć.

Ostatnio poznałem nowe, bardzo ciekawe podejście. Polega ono na stworzeniu dodatkowej, pomocniczej "pustej" usługi i zainstalowaniu jej w odpowiedni sposób razem z właściwą usługą. Strukturę przykładowego projektu widać na poniższym rysunku.



Projekt musi również zawierać dwa instalatory (klasa ServiceInstaller), po jednym dla każdej z usług.



W kodzie przekłada się to na coś takiego.
...
this.Installers.AddRange(new System.Configuration.Install.Installer[] {
  this.serviceProcessInstaller1,
  this.serviceInstaller1,
  this.serviceInstaller2});
...
Instalację przeprowadzamy standardowo przy pomocy narzędzia InstallUtil. Zadba ono o to aby zainstalować obie usługi za jednym razem. Następnie, przy debugowaniu najpierw uruchamiamy tą drugą, dodatkową usługę, podczepiamy się do niej przy pomocy opcji Attach to process..., stawiamy pułapkę w kodzie pierwszej usługi i dopiero ją uruchamiamy. To zadziała ponieważ obie usługi zostaną uruchomione w jednym procesie, a co więcej otrzymujemy to za darmo. Odpowiada za to metoda ServiceIntaller.Install, której fragment przytaczam poniżej.
int serviceType = 0x10;
...
if (numberOfServices > 1)
{
  serviceType = 0x20;
}
...
NativeMethods.CreateService(databaseHandle, this.ServiceName, this.DisplayName, 0xf01ff, serviceType, (int) this.StartType, 1, str3, null, IntPtr.Zero, dependencies, servicesStartName, password);
...
CreateService to natywna metoda WinAPI, która dodaje usługę do bazy danych menadżera usług. Istotny jest jej piąty parametr serviceType, może przyjąć kilka wartości ale nas interesują dwie:
  • SERVICE_WIN32_OWN_PROCESS = 0x00000010 oznacza, że każda usługa działa w swoim procesie
  • SERVICE_WIN32_SHARE_PROCESS = 0x00000020 oznacza, że usługi mogą dzielić jeden proces
Jeśli liczba usług jest większa niż 1 to ServiceIntaller.Install używa flagi SERVICE_WIN32_SHARE_PROCESS, a w przeciwnym wypadku SERVICE_WIN32_OWN_PROCESS.

12/09/2011

IntelliTrace - problem ze zdarzeniem

Home

Jakiś czas temu pracując z IntelliTrace próbowałem zdefiniować zdarzenie diagnostyczne dla pewnej metody. Dla ustalenia uwagi niech jej sygnatura wygląda tak, jak poniżej.

string Flip(string s)

Moim celem było, aby opis zdarzenia zawierał wynik zwrócony przez metodę oraz wartość argumentu s. Inaczej mówiąc, aby w oknie IntelliTrace Events View w Visual Studio 2010 zdarzenie zarejestowane dla wywołania metody z argumentem s="Hello" i wynikiem "olleH" wyglądalo tak:

"olleH" Flip("Hello")

O definiowaniu zdarzeń IntelliTrace już pisałem (Własne zdarzenia IntelliTrace!, Własne zdarzenia IntelliTrace 2) dlatego nie będę opisywał całego procesu. Przytoczę już gotową definicję zdarzenia:
<DiagnosticEventSpecification enabled="true">
  <Bindings>
    <Binding>
      <ModuleSpecificationId>FibTest.exe</ModuleSpecificationId>
        <TypeName>Utilities</TypeName>
        <MethodName>Flip</MethodName>
        <MethodId>Utilities.Flip(System.String):System.String</MethodId>
        <ShortDescription _locID="shortDescription.Utilities.Flip">"{0}" Flip("{1}")</ShortDescription>
        <LongDescription _locID="longDescription.Utilities.Flip">"{0}" Flip("{1}")</LongDescription>
        <DataQueries>
          <DataQuery index="-1" maxSize="100" type="String" query="" />
          <DataQuery index="1" maxSize="100" type="String" query="" />
        </DataQueries>
        <ProgrammableDataQuery>
          <ModuleName></ModuleName>
          <TypeName></TypeName>
        </ProgrammableDataQuery>
    </Binding>
  </Bindings>
  ...
</DiagnosticEventSpecification>
Niestety ku moje zdziwieniu to nie zadziałało. Zdarzenie zostało zarejestrowane, ale w oknie IntelliTrace Events View zamiast zobaczyć upragniony wynik otrzymałem komunikat: An error occured while fetching the data for this event. Zajrzałem, więc do wcześniej zdefiniowanych przez siebie zdarzeń i przypomniałem sobie o jednej rzeczy. Aby odwołać się do wartości zwróconej przez metodę należy ustawić atrybut onReturn.
...
<Binding onReturn="true">
...
Niestety to też nie pomogło. Ponownie zajrzałem więc do wcześniej przygotowanych zdarzeń i na pierwszy rzut oka wszystko wyglądało tak samo. Po chwili zastanowienia doszedłem do wniosku, że przyczyną kłopotów może być to, że próbuję odwołać się zarówno do wartości argumentów jak i do wartości zwracanej przez metodę. Wcześniej czegoś takiego nie próbowałem. Zamiast jednego przygotowałem więc dwa zdarzenia. W jednym odczytuję wartość argumentu przekazanego do metody, a w drugim wynik zwrócony przez metodę.
...
<Binding onReturn="false">
  ...
  <ShortDescription _locID="shortDescription.Utilities.Flip">Flip("{0}")</ShortDescription>
  <LongDescription _locID="longDescription.Utilities.Flip">Flip("{0}")</LongDescription>
  <DataQueries>
    <DataQuery index="1" maxSize="100" type="String" query="" />
  </DataQueries>
  ...
</Binding>
...
...
<Binding onReturn="true">
  ...
  <ShortDescription _locID="shortDescription.Utilities.Flip">Flip returns "{0}"</ShortDescription>
  <LongDescription _locID="longDescription.Utilities.Flip">Flip returns "{0}"</LongDescription>
  <DataQueries>
    <DataQuery index="-1" maxSize="100" type="String" query="" />
  </DataQueries>
  ...
</Binding>
...
To zadziałało, zostały zarejestrowane dwa zdarzenia, jedno z opisem Flip("Hello"), a drugie z opisem Flip returns "olleh". Moim zdaniem to spore ograniczenie IntelliTrace, ale nie ma rady i trzeba o tym po prostu pamiętać.

04/09/2011

IntelliTrace - schemat XSD

Home

Swego czasu w postach Własne zdarzenia IntelliTrace! oraz Własne zdarzenia IntelliTrace 2 opisałem jak zmodyfikować plik CollectionPlan.xml zawierający plan działania IntelliTrace (historycznego debuggera) tak, aby zdefiniować swoje własne zdarzenie IntelliTrace (ważny punkt w historii działania programu kiedy IntelliTrace nagrywa stan aplikacji). Ostatnio wróciłem do tego zagadnienia i "bawię się" testując różne możliwości IntelliTrace. Niestety czasami, po zmodyfikowaniu pliku CollectionPlan.xml, przy próbie uruchomienia debuggera otrzymywałem błąd np.:

error VSLG4001: The specified collection plan is invalid: 'C:\CollectionPlan.xml'. More information: The 'type' attribute is invalid - The value 'Object' is invalid according to its datatype 'urn:schemas-microsoft -com:visualstudio:tracelog:ClrType' - The Enumeration constraint failed.

Komunikat jest czytelny, wartość Object dla atrybutu type jest niedozwolona. Metodą prób i błędów można wywnioskować, jakie wartości są poprawne, ale to męczące i niewydajne. Pomyślałem więc, że skoro zawartość pliku CollectionPlan.xml to dokument XML, to musi on być walidowany przy pomocy odpowiedniego schematu XSD. Ale gdzie go szukać? Przejrzałem zawartość katalogu instalacyjnego Visual Studio 2010 i niczego nie znalazłem.

Stwierdziłem więc, że zajrzę do biblioteki Microsoft.VisualStudio.IntelliTrace (pisałem o niej w poście Poznaj swój program), która umożliwia programową analizę logów IntelliTrace, ale nie tylko. Biblioteka ta wykorzystywana jest również przez program IntelliTrace.exe (o tym też pisałem w poście Używanie IntelliTrace poza Visual Studio 2010!), który znajdziemy w katalogu instalacyjnym Visual Studio 2010. Program ten służy do uruchomienia historycznego debuggera i kiedy używamy IntelliTrace z poziomu Visual Studio, to korzystamy właśnie z tego programu. Skoro tak to doszedłem do wniosku, że walidacja konfiguracji i schemat XSD znajdują się właśnie w tej bibliotece.

Okazało się to strzałem w dziesiątkę. Bibliotekę załadowałem do .NET Reflector'a. Najpierw zlokalizowałem w zasobach komunikat z błędem. Następnie sprawdziłem gdzie jest używany i znalazłem tylko jedno takie miejsce, a stamtąd już szybko doszedłem do wywołania metody public static XmlSchema GenerateXmlSchema(). Niestety okazało się, że nie mogę jej wywołać z własnego kodu, ponieważ znajduje się w klasie internal class ConfigMessagePacker. Skopiowałem więc jej kod (ok. 1200 linii) do swojego programu i po chwili miałem już XSD.

31/08/2011

Krótko o instalowaniu ServicedComponent

Home

W dwóch poprzednich artykułach na temat zarządzanych komponentów COM+ pisałem, że instaluje się je przy użyciu narzędzia regsvcs.exe. Tak oczywiście jest, ale ostatnio ku swojemu zaskoczeniu zauważyłem, że jest to opcjonalne. Jeśli nie zainstalujemy takiego komponentu z poziomu konsoli (np.: regsvcs.exe MyComponent.dll) to zostanie on zainstalowany automatycznie przy pierwszym wywołaniu jego konstruktora.
[assembly: ApplicationName("MyComponent")]
[assembly: ApplicationActivation(ActivationOption.Server)]
[assembly: System.Reflection.AssemblyKeyFile("MyComponent.snk")]
[assembly: ApplicationAccessControl(false)]
public class MyComponent: ServicedComponent
{
  ...
}

...

//Jeśli komponent nie został wcześniej zainstalowany to zostanie zainstalowany w tym momencie
using (MyComponent cmp = new MyComponent())
{
  ...
}


25/08/2011

Jeszcze o ServicedComponent

Home

W tym poście wrócę do tematu aplikacji modelu COM+ napisanych w kodzie zarządzanym, który to poruszyłem w poprzednim artykule. Tym razem chciałbym zwrócić uwagę na problem wersjonowanie takich komponentów. Upraszczając, chodzi o różnicę pomiędzy katalogiem, z jakiego komponent został zainstalowany w systemie, a katalogiem, w którym znajduje się biblioteka z komponentem jakiej używa dana aplikacja. W szczególności mogą to być inne katalogi np.: c:\Install oraz c:\bin.

W takim przypadku łatwo może dość do sytuacji, w której binaria w obu lokalizacjach będą się różnić. Objawy będą różne w zależności od trybu aktywacji (pisałem o nich poprzednio).W przypadku Aplikacji biblioteki tak długo jak w obu katalogach będą znajdować się biblioteki skompilowane dla tego samego środowiska (32 lub 64 bitowego) nie będzie żadnego problemu (poza bałaganem). Co ciekawe znaczenie ma tylko liczba bitów, wersje bibliotek mogą być inne, różna może być nawet liczba udostępnianych przez komponent metod. Natomiast w przypadku kiedy w jednym katalogu będzie wersja 32 bitowa, a w drugim 64 bitowa przy próbie skorzystania z komponentu dostaniemy wyjątek ComException o treści Klasa niezarejestrowana....

W trybie Aplikacji serwerowej liczba bitów nie ma znaczenia. Znaczenie ma jednak zawartość bibliotek w obu lokalizacjach. Jeśli będzie różna debugowanie nie będzie możliwe. Jeśli zmieni się interfejs, na przykład do biblioteki zostanie dodana nowa metoda, ale nowa wersja nie zostanie zainstalowana, to przy próbie jej użycia pojawi się wyjątek.

Generalnie problem dotyczy też natywnych komponentów COM+ (piekło COM+) ale z takimi komponentami nie pracowałem dlatego nie znam szczegółów.

23/08/2011

Debugowanie ServicedComponent

Home

ServicedComponent to klasa umożliwiająca tworzenie zarządzanych komponentów/klas, które mogą być użyte w aplikacjach COM+ oraz mogą korzystać z usług COM+. Jedną z takich usług jest na przykład pula obiektów, czyli coś podobnego do puli połączeń z tą różnicą, że możemy w niej umieścić instancje naszej własnej klasy.

Aby stworzyć taką specjalną klasę należy wydziedziczyć ją ze wspomnianej klasy ServicedComponent. Do tej pory nie miałem okazji z niej korzystać, dlatego napotkałem pewne kłopy przy debugowaniu takich zarządzanych komponentów COM+ (dalej będę używał po prostu pojęcia komponent).

Należy zacząć od tego, że są dwa tryby aktywacji komponentów COM+ (zarządzanych lub nie). W pierwszym (tzw. Aplikacja biblioteki/Library application) komponent aktywowany jest w procesie aplikacji, która z niego korzysta. W drugim trybie natomiast (tzw. Aplikacja serwera/Server application) aktywacja przeprowadzana jest przez dedykowany proces. Tworząc taki komponent możemy określić tryb aktywacji przy pomocy atrybutu ApplicationActivationAttribute. Tryb ten można również zmienić już po zainstalowaniu komponentu (przy pomocy narzędzia regsvcs.exe) w konsoli zarządzania w przystawce Usługi składowe (ang. Component services). Znajdziemy ją w lokalizacji C:\Windows\System32\comexp.msc.

Debugowanie takiego zarządzanego komponentu różni się w zależności od trybu aktywacji. Zacznijmy od pierwszego przypadku czyli tzw. Aplikacji biblioteki. Tutaj sprawa generalnie jest prosta. Skoro komponent aktywowany jest w procesie aplikacji, która z niego korzysta to wystarczy postawić pułapkę w odpowiednim miejscu np.: w kodzie komponentu i tyle. Jest jedno ale. To nie zadziała jeśli aplikacja korzystająca z komponentu została skompilowana na platformę .NET 4.0. Komponent będzie zwracał poprawne wyniki ale jak do tej pory nie udało mi się zmusić VS 2010 do zatrzymania się na pułapce ustawionej w kodzie komponentu albo przejść do tego kodu przy pomocy F11. Jeśli zmienimy platformę na przykład na .NET 3.5 to problem z debugowaniem nie wystąpi.

Trzeba również wiedzieć, że w przypadku tego trybu aktywacji, jeśli mamy zainstalowany komponent 32 bitowy, to proces, który chce z niego skorzystać również musi być 32 bitowy. Analogicznie dla 64 bitów. Informację o błędzie dostaniemy już przy próbie wywołania konstruktora komponentu.

W przypadku trybu "serwerowego" komponent aktywowany jest w innym procesie, więc wersja platformy czy nawet liczba bitów nie mają znaczenia (również przy debugowaniu). Z drugiej strony mamy inny problem ponieważ musimy doczepić się do tego procesu aby go zdebugować czyli skorzystać z funkcji Debug->Attach to Process.... Interesujący nas proces nazywa się dllhost.exe. Kłopot w tym, że na liście może znajdować się kilka procesów o tej nazwie. Pierwsze przybliżenie uzyskamy zawężając listę do tych procesów, które w kolumnie Type mają wartość Managed.... W przypadku gdy jest ich kilka można skorzystać z programu Process Explorer i sprawdzić identyfikator procesu, który korzysta z biblioteki z naszym komponentem.

W jednym z kolejnych postów wrócę jeszcze do tematu i napisze o problemach z wersjonowaniem omawianych komponentów.

07/08/2011

Kłopoty z Client Profile

Home

The type or namespace name 'SomeType' does not exist in the namespace 'SomeNamespace' (are you missing an assembly reference?)

Sądzę, że każdy programista .NET spotkał się z powyższym błędem kompilacji. Nie jest to nic wyjątkowego i rozwiązanie problemu jest bardzo łatwe, wystarczy dodać do projektu referencję do brakującej biblioteki. Czy oby na pewno?

Kilka dni temu napotkałem powyższym błąd kompilatora i rozwiązanie problemu zajęło mi sporo więcej czasu niż normalne kilkanaście sekund. Na początku zdziwiłem się ponieważ niekompilujący się projekt aplikacji zawierał potrzebną referencję, kilka minut wcześniej sam ją dodałem. Na wszelki wypadek podpiąłem ją jednak jeszcze raz i przekompilowałem wszystkie potrzebne projekty ale nic to nie dało. Restart Visual Studio również nie pomógł. Kilka kolejnych prób kompilacji również spełzło na niczym.

Trochę zrezygnowany zabrałem się do przeglądania ustawień felernego projektu i zwróciłem uwagę na to, że korzysta on .NET Framework 4 Client Profile. Wcześniej nie miałem z tym problemu ale przypomniałem sobie, że w projektach tego typu nie można korzystać z bibliotek, których nie ma w NET Framework 4 Client Profile. Na próbę zmieniłem opcję Target framework na .NET Framework 4 i okazało się to strzałem w dziesiątkę.

W ramach testu zacząłem zmieniać różnym projektom opcję Target framework na .NET Framework 4 Client Profile i sprawdzać czy się kompilują. Okazało się, że prawie wszystkie skompilowały się bez żadnego problemu. Dalsze eksperymenty doprowadziły mnie do następującego wniosku:

Błąd zostanie zgłoszony jeśli w projekcie korzystającym z NET Framework 4 Client Profile użyjemy biblioteki, która bezpośrednio lub pośrednio korzysta z czegoś co nie znajduje się w NET Framework 4 Client Profile.

Przez użyjemy rozumiem np.: stworzenie instancji klasy. Samo dodanie referencji do biblioteki czy nawet zaimportowanie przestrzeni nazw przy pomocy using nie spowoduje błędu. Ważne jest również to, że biblioteka powodująca błąd może znajdować się gdzieś daleko w łańcuchu referencji, co jeszcze utrudnia sprawę. Należy również wiedzieć, że o przynależności lub nie do NET Framework 4 Client Profile nie decydują opcje kompilacji ale to z czego korzystamy w danej bibliotece. Listę "zabronionych" rzeczy można znaleźć tutaj. W moim przypadku błąd powodowała "zabroniona" biblioteka System.Data.OracleClient, z której korzysta Microsoft.Practices.EnterpriseLibrary.Data.dll, którą z kolei używam ja.

Sądzę, że warto o tym pamiętać aby zaoszczędzić sobie nerwów szczególnie, że kiedy dodajemy nowy projekt aplikacji (WPF, WinForms, konsola) to domyślnie będzie on korzystał .NET Framework 4 Client Profile. W większości przypadków jest to dla nas przezroczyste ale zawsze może trafić się ten jeden raz.

27/07/2011

IntelliTrace - Reaktywacja

Home

Już kawał czasu temu w artykule Używanie IntelliTrace poza Visual Studio 2010! opisałem w jaki sposób uruchomić narzędzie IntelliTrace poza środowiskiem Visual Studio 2010. Przedstawiona przeze mnie metoda miała jednak wadę. Log z nagranym przebiegiem wykonania programu zawierał "tylko" informację o wywołaniach metod czyli drzewo wywołań, a brakowało w nim informacji o zdarzeniach diagnostycznych czyli ważnych punkty w historii wykonania programu np.: wykonanie zapytania do bazy danych.

Ostatnio udało mi się znaleźć ostatni element układanki. Otóż aby log IntelliTrace zawierał wszystkie niezbędne informacje wystarczy wykonać, oprócz opisanych już przeze mnie rzeczy, jeszcze jeden krok czyli zmodyfikować plik CollectionPlan.xml z planem działania/konfiguracją narzędzia. Znajdujemy w nim linię:
...
<DiagnosticEventInstrumentation enabled="false">
...
i zamieniamy na:
...
<DiagnosticEventInstrumentation enabled="true">
...

05/06/2011

Aplikacje wielojęzyczne - WPF

Home

Przystępując do tłumaczenia aplikacji WPF miałem dokładny plan jak się za to zabrać. Mianowicie postanowiłem użyć narzędzia LocBaml, o którym dowiedziałem się z training kit'a do egzaminu 70-502. Opis całej procedury można znaleźć tutaj. Praktyka pokazała jednak, że narzędzie to pozostawia bardzo dużo do życzenia. Dalej opiszę kolejne kroki pracy z LocBaml wraz z komentarzem jak to wygląda w praktyce.

UIDs

Mechanizm wielojęzyczności aplikacji w WPF koncepcyjnie zbliżony jest to tego co znamy z ASP.NET. W szczególności każda kontrolka, która posiada jakieś zasoby do przetłumaczenia powinna mieć odpowiedni identyfikator tzw. UID. Identyfikatory te można nadać automatycznie przy pomocy polecenia:

msbuild /t:updateuid NAZWA_PROJEKTU.csproj

Wszystkie pliki XAML, które mają zostać przetworzone przez msbuild powinny być checkoutowane (brzmi okropnie ale nie przychodzi mi do głowy dobry polski odpowiednik). Narzędzie to działa i dobrze i źle. Dobrze bo rzeczywiście wygeneruje dla wszystkich kontrolek UID. Źle bo zrobi to dla wszystkich kontrolek, a właściwie powinienem napisać dla wszystkich elementów dokumentu XAML. msbuild nie wykonuje jakiejkolwiek analizy przetwarzanych elementów. Skutkuje to dużym bałaganem. Po przeprowadzeniu tej operacji w plikach XAML pojawi się bardzo dużo, niepotrzebnych, śmieciowych UID'ów, na przykład po co nadawać identyfikator UID kontrolce Border, Line lub StackPanel. Potem aż bolą oczy jak się patrzy na tak przetworzony XAML. Moja rada jest taka. Jeśli chcemy stosować LocBaml to zawczasu, tworząc interfejs użytkownika, powinniśmy nadawać kontrolkom UID'y.

Wyciąganie zasobów do przetłumaczenia

Po wygenerowaniu UID'ów przechodzimy do następnego kroku czyli używamy LocBaml aby wyciągnać zasoby do przetłumaczenia do pliku CSV. Służy do tego takie polecenie:

LocBaml.exe /parse NAZWA_PROJEKTU.resources.dll /out:NAZWA_PLIKU_WYJSCIOWEGO.CSV

Skompilowane zasoby czyli pliki dll umieszczane są w podkatalogach o nazwach zgodnych z kulturą (językiem) zasobów np.: en-US, pl-PL itp. Jeśli nasz projekt nazywa isę SimpleApplication to plik dll z zasobami będzie nazywał się SimpleApplication.resources.dll. W praktyce wydanie tego polecenia skończy się błędem:

Could not load file or assembly '...' or one of its dependencies. The system cannot find the file specified.

Rozwiązanie jest proste ale trochę upierdliwe. Otóż plik LocBaml.exe, plik z zasobami NAZWA_PROJEKTU.resources.dll oraz plik exe/dll powstały po skompilowaniu aplikacji/biblioteki muszą znajdować się w tym samym folderze. Najszybciej to oskryptować. Podobny ale trochę inny komunikat o błędzie:

Could not load file or assembly '...' or one of its dependencies. An attempt was made to load a program with an incorrect format.

Otrzymamy jeśli nasza aplikacja ma ustawioną docelową platformę na x86. W takim wypadku możemy zmienić ustawienia naszej aplikacji, przekompilować LocBaml na x86 albo użyć narzędzia corflags o czym już zresztą pisałem tutaj lub tutaj.

Tak wygenerowany plik CSV powinniśmy teraz przetłumaczyć. Jeśli użyliśmy msbuild do wygenerowania UID'ów to plik ten będzie zawierał dla dużej aplikacji nawet kilkanaście tysięcy wierszy. W moim przypadku było to około 13 tysięcy wierszy z czego jakieś 10% wymagało przetłumaczenia!!! Po prawdzie tak duża liczba wierszy jest również związana ze sposobem działania LocBaml. Otóż dla każdej kontrolki z UID'em w pliku CSV znajdziemy wiele wierszy. Na przykład dla kontrolki TextBlock możemy zlokalizować, co oczywiste, właściwość Text ale również mniej sensowne jak Foreground czy Margin.

Stworzenie pliku dll z przetłumaczonymi zasobami

To jest chyba najtrudniejszy, a zarazem najmniej wygodny krok w całym procesie. W teorii jest to proste. Bierzemy przetłumaczony plik CSV i używamy LocBaml do wygenerowania dll'ki z zasobami o nazwie NAZWA_PROJEKTU.resources.dll, a następnie umieszczamy ją w odpowiednim katalogu aplikacji np.: en-US. Za wczytanie odpowiedniej wersji zasobów odpowiada już silnik WPF, a decyzje podejmuje podobnie jak w ASP.NET czy WinForms na podstawie właściwości Thread.CurrentThread.CurrentUICulture.

Problem pierwszy związany jest z bugiem w implementacji LocBaml, który objawia się tym, że identyfikatory zasobów w pliku CSV mogą się powtarzać. W związku z tym proces odwrotny czyli wygenerowanie dll'ki na podstawie pliku CSV się nie powiedzie. Rozwiązanie problemu można znaleźć tutaj i jest ono trywialne ale wymaga rekompilacji projektu. Można też, ale tego nie próbowałem, zapewnić, że wszystkie pliki XAML w naszym projekcie mają inne nazwy nawet jeśli znajdują się w innych katalogach.

Pierwszy problem w porównaniu z drugim to nic. Wyobraźmy sobie taką sytuację. Mamy projekt aplikacji WPF i grzecznie, zgodnie z zasadami wszystkie komunikaty wyświetlane użytkownikowi trzymamy w plikach zasobów resx. Po jakim czasie chcemy przygotować angielską wersję aplikacji. Zaczynamy od przetłumaczenia tych zasobów, a więc plik np.: msg.resx kopiujemy i zmieniamy mu nazwę na msg.en-US.resx, a następnie tłumaczymy.

Teraz zabieramy się za XAML. Nadajemy kontrolką UID'y, wyciągamy zasoby do pliku CSV, tłumaczymy i ponownie używamy narzędzie LocBaml do wygenerowania dll'ki z zasobami. Wszystko poszło jak po maśle i zadowoleni chcemy skopiować świeżutką dll'ke z przetłumaczonymi zasobami do katalogu en-US, a tu figa z makiem. W katalogu znajduje się już dll'ka o takiej nazwie. Skąd się wzięła? Została wygenerowana przez VS i zawiera komunikaty, które umieściliśmy w pliku msg.en-US.resx. Mamy więc dwie dll'ki o takiej samej nazwie, jedną z przetłumaczonymi komunikatami, a drugą z przetłumaczonym GUI i musimy umieścić je w tym samym katalogu.

I tutaj zaczynają się schody, wysokie, wąskie, ciemne i niewygodne. Nie pozostaje nic innego jak użyć linkera al.exe. Da się to zrobić ale tak jak powiedziałem jest to bardzo niewygodne. Nie będę tutaj tego opisywał bo moim zdaniem jest to strasznie nudne. Jeśli ktoś tego potrzebuje to zapraszam do kontaktu.

Inne

Takie podejście do lokalizowania aplikacji WPF ma również inne wady. Tak jak wspomniałem wersja zasobów jaka zostanie wczytana zależy od właściwości Thread.CurrentThread.CurrentUICulture. Trzeba więc pamiętać aby ustawić ją na odpowiednią kulturę zanim zaczniemy robić cokolwiek z interfejsem użytkownika. W przeciwnym wypadku możemy doprowadzić do wczytania złej wersji zasobów i okaże się, że cześć okien będzie w języku polskim, a część w angielskim. Po drugie, modyfikacja Thread.CurrentThread.CurrentUICulture nie powoduje automatycznego przełączenia się pomiędzy jedną, a drugą wersją zasobów. Dopiero zamknięcie okna i ponowne jego wyświetlenie spowoduje pobranie odpowiedniej wersji językowej zasobów.

Podsumowanie

LocBaml można użyć, pytanie czy z wszystkimi wadami i ograniczeniami tego narzędzia warto. W omawianym przypadku skończyło się to użyciem wyszukanej przez kolegę biblioteki WPFLocalizeExtension. Też ma swoje wady, też nie jest idealna, w szczególności w żaden sposób nie automatyzuje procesu lokalizowania aplikacji ale nie wymaga UID'ów, nie trzeba pisać skryptów, linkować itd. Tutaj zresztą kłania się to co już pisałem dwa razy:

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


26/05/2011

Aplikacje wielojęzyczne - WinForms

Home

Przyszła pora wrócić do tematu aplikacji wielojęzycznych. Tym razem skupię się na WinForms. Zacznę od tego, że część rzeczy, o których pisałem we wcześniejszym poście na temat aplikacji ASP.NET można zastosować do innych technologii, w szczególności do WinForms. Dla przypomnienia:
  • Jeśli chcemy aby nasza aplikacja miała wiele wersji językowych to przygotowujmy się do tego od pierwszej linijki tej aplikacji.
  • Stałe znakowe w kodzie są złe, bardzo złe, niewyobrażalnie złe... 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.: klasie o nazwie Constans.
Tyle tytułem wstępu. Kiedy przystępowałem do prac nad aplikacją WinForms byłem o tyle w gorszej sytuacji w porównaniu do wcześniejszych prac nad aplikacją ASP.NET, że nie wiedziałem o żadnych narzędziach wbudowanych w Visual Studio wspomagających lokalizację WinForms. Co do samego mechanizmu przełączania się pomiędzy różnymi wersjami językowymi zasobów to wygląda to tak samo jak w ASP.NET. Mamy więc odpowiednio ponazywane pliki np.: Resources.resx, Resources.en.resx itd. zawierające zasoby dla poszczególnych języków (kultur). Teraz w zależności od tego jaką kulturę ustawimy na właściwości Thread.CurrentThread.CurrentUICulture taka wersja zasobu zostanie wczytana. Na samym początku pomyślałem więc aby przejrzeć kod wygenerowany przez designer i w nim pozamieniać stałe znakowe na odwołania do zasobów. Podobnie postąpiłem przecież dla ASP.NET. Czyli poniższy kod:
...
// 
// cancelButton
// 
this.cancelButton.Location = new System.Drawing.Point(85, 103);
this.cancelButton.Name = "cancelButton";
this.cancelButton.Size = new System.Drawing.Size(75, 23);
this.cancelButton.Text = "Anuluj";
...
Zamienić na taki:
...
// 
// cancelButton
// 
this.cancelButton.Location = new System.Drawing.Point(85, 103);
this.cancelButton.Name = "cancelButton";
this.cancelButton.Size = new System.Drawing.Size(75, 23);
this.cancelButton.Text = Resources.Cancel;
...
To nie jest jednak dobry pomysł przynajmniej z kilku powodów. Kod generowany przez designer WinForms nie jest przeznaczony do samodzielnej modyfikacji. Podejście to zadziała ale wystarczy, że ktoś użyje designera aby zmienić położenie kontrolki lub zrobić coś równie prostego, a kod zostanie ponownie wygenerowany, a nasze zmiany usunięte. Po drugie przeglądanie kodu designera w poszukiwaniu stałych znakowych to żmudna, nudna i błędogenna robota. W następnej kolejności pomyślałem więc o innym rozwiązaniu:
public Form1()
{
  InitializeComponent();
  cancelButton.Text = Resources.Cancel;
  ...
}
Czyli zanim wyświetlimy formę, pobieramy zasoby i lokalizujemy GUI. Podejście bardzo proste, wręcz prymitywne. Można je trochę udoskonalić na przykład dodać do bazowej klasy metodę LocalizeGUI:
public class BaseForm : Form
{
  protected override void OnShown(EventArgs e)
  {
    LocalieGUI();
    base.OnShown(e);
  }

  protected virtual void LocalieGUI()
  {}
}
...
public partial class Form1 : BaseForm
{
  protected override void LocalieGUI()
  {
    cancelButton.Text = Resources.Cancel;
    ...
  }
}
Również bardzo proste rozwiązanie i można jeszcze dużo w nim zmienić. Zasadniczy problem polega jednak na tym, że takie rzeczy sprawdzają się kiedy używa się ich od początku. Ja dostałem gotową aplikację i jak wyobraziłem sobie przeglądanie całego kodu w poszukiwaniu etykiet, przycisków itd., a następnie przenoszenie ustawiania tekstu wyświetlanego użytkownikowi do metody LocalizeGUI to mi się odechciało.

Na szczęście istnieje dużo lepsze rozwiązanie problemu. Otóż Visual Studio posiada wsparcie dla wielojęzycznych WinForms i w przeciwieństwie do narzędzia Tools->Generate Local Resources dla ASP.NET działa całkiem dobrze. Używa się go bardzo prosto:
  • Otwieramy designer dla formy (kontrolki użytkownika).
  • Otwieramy okno właściwości (Properties) dla formy (kontrolki użytkownika).
  • Pole Localizable z kategorii Design ustawiamy na True i zapisujemy zmiany. W tym momencie designer wyniesie do pliku o nazwie NAZWA_FORMY.resx wszystkie stałe znakowe, które podlegają lokalizacji. Jeśli zajrzymy teraz do kodu generowanego przez designer to będzie on wyglądał trochę inaczej niż wcześniej. W szczególności nie znajdziemy tam kodu takie jak this.cancelButton.Text = "Anuluj";, a taki resources.ApplyResources(this.cancelButton, "cancelButton"); gdzie resources to obiekt klasy ComponentResourceManager. Co bardzo ważne dla kontrolek, które dodamy do formy później będzie to wyglądało tak samo.
  • Wracamy do okna właściwości (Properties).
  • Zmieniamy pole Language z Default na docelowy język np.: English.
  • Przechodzimy do okna designera i dokonujemy tłumaczenia. Czyli przycisk 'Anuluj' zamieniamy na 'Cancel', a etykietę 'Nazwa' na 'Name' itd.
  • Zapisujemy zmiany. W projekcie pojawi się nowy plik z zasobami o nazwie NAZWA_FORMY.JEZYK.resx.
  • Przywracamy poprzednią wartość pola Language czyli Default. Zawartość wszystkich zlokalizowanych kontrolek powinna wrócić do stanu wyjściowego.
  • Proces powtarzamy dla innych języków.
Z narzędziem tym pracuje się naprawdę przyjemnie. Trzeba tylko pamiętać, że w designerze na pierwszy rzut oka nie widać wszystkich rzeczy do przetłumaczenia na przykład pozycji menu. Praktyka pokazało również, że narzędzie to współpracuje z kontrolkami zewnętrznych dostawców. Mam tylko dwa zastrzeżenia. W jednym przypadku zmodyfikowany przez designer kod nie chciał się potem kompilować i musiałem go poprawić. Po drugie, o czym pisałem już w poście dotyczącym ASP.NET, jeśli nasza aplikacja składa się z wielu okien to otrzymamy wiele plików z zasobami. Moim zdaniem wprowadza to niestety bałagan do projektu i zmusza nas do wielokrotnego tłumaczenie tych samych tekstów.

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.

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

01/04/2011

Zabawy z pozycjonowaniem okna w WPF

Home

Czasami zachodzi potrzeba "ręcznego" pozycjonowania okna w aplikacji WPF. Sprawa jest prosta i sprowadza się do odpowiedniego ustawienia właściwości Left oraz Top. Jeśli chcemy aby pozycja okna dziecka zależała od położenia rodzica to możemy odwołać się do rodzica przez właściwość Owner i napisać taki kod jak poniżej. Kod ten powoduje przesunięcie okno dziecka o 50 jednostek w prawo względem lewej krawędzi okna rodzica.
...
myWindow.Left = myWindow.Owner.Left + 50;
...
Nic prostrzego. Jest tu jednak mały haczyk. Jeśli okno rodzica jest zmaksymalizowane to właściwość Left i Top dla rodzica będą miały wartość wskazującą położenie okna przed zmaksymalizowaniem. Poniżej kod, który to uwzględnia:
...
myWindow.Left = (myWindow.Owner.WindowState == WindowState.Maximized ? 0 : myWindow.Owner.Left) + 50;
...
Nic trudnego ale trzeba o tym pamiętać. Sprawa robi się jeszcze ciekawsza kiedy pracujemy z wieloma monitorami. W takim wypadku powyższy kod będzie działał tylko dla głównego monitora. Jeśli okno rodzica będzie zmaksymalizowane i będzie znajdować się na innym monitorze niż główny to wykonanie tego kodu spowoduje wyświetlenie okna dziecka na głównym monitorze! Poniżej rozwiązanie tego problemu:

if (myWindow.Owner.WindowState == WindowState.Maximized)
{
  Screen s = Screen.FromHandle(new WindowInteropHelper(myWindow.Owner).Handle);                      
  myWindow.Left = s.WorkingArea.Left + 50;
}
else
{
  myWindow.Left = myWindow.Owner.Left + 50;
}
                    
Korzystam tutaj ze starej poczciwej klasy Screen, przy pomocy której znajduję monitor, na którym wyświetlane jest okno rodzica. Następnie odczytuję położenie lewej krawędzi tego monitora. Wartość ta będzie różna w zależności od tego, z którym monitorem mamy do czynienia.

19/03/2011

Jak właściwość może napsuć krwi

Home

Właściwości to jedna z moich ulubionych cech języka C#. W poście tym nie będę jednak przekonywał opornych, jeśli tacy są, do ich używania. Opiszę natomiast przypadek złego wykorzystania właściwości, który ostatnio napsuł mi trochę krwi. Załóżmy, że mamy aplikację WWW lub grubego klienta. Aplikacja ta komunikuje się z bazą danych za pośrednictwem tzw. dostawców danych czyli klas implementujących dobrze określone interfejsy. W zależności od konfiguracji dostawca może być dostępny lokalnie w ramach procesu aplikacji lub zdalnie czyli być uruchomiony na innej maszynie np. jako usługa WCF. Z perspektywy aplikacji jest to przezroczyste.

Dla ustalenia uwagi przyjmijmy, że dostawca udostępnia metodę Customer GetCustomer(int customerId), która zwraca obiekt klasy Customer reprezentujący klienta o podanym identyfikatorze. Problem polega na tym, że w zależności od tego czy dostawca danych działa lokalnie czy zdalnie to dla tego samego identyfikatora zwraca trochę inne dane. Dokładniej mówiąc jeśli mamy do czynienia z dostawcą lokalnym wszystko jest w porządku. Natomiast dla dostawcy zdalnego zwracane dane są niekompletne w tym sensie, że nie wszystkie właściwości obiektu Customer są ustawione. Co ciekawe po zdebugowaniu procesu serwera okazuje się, że zwracane przed dostawcę dane są poprawne ale po dotarciu do aplikacji okazują się jednak niekompletne.

Początkowo przecierałem oczy ze zdziwienia bo jak to możliwe, że część danych nagle znika. Po zagłębieniu się w problem znalazłem jednak winnego, którym okazała się błędne zaimplementowana właściwość w połączeniu z serializacją XML'ową. Istotę problemu przedstawia poniższy kod:

private string _StringValue;
public string StringValue
{
  get
  {
    if(_StringValue == "aaa")
      return "Ala ma kota";
    else if(_StringValue == "bbb")
      return "Kot ma Alę";
    ...
    else
      return null;
  }
  set
  {
    _StringValue = value;
  } 
}

Jak widać wartość zwracana przez getter właściwości jest inna niż wartość ustawiana przy pomocy settera. W przypadku kiedy dostawca działa lokalnie nie stanowi to żadnego problemu ponieważ pracujemy bezpośrednio z klasą dostawcy danych. W przypadku kiedy dostawca uruchomiony jest zdalnie to w rzeczywistości po stronie aplikacji pracujemy z obiektem proxy. W momencie wywołania metody GetCustomer do serwera wysyłane jest odpowiedni komunikat i wywoływana jest "prawdziwa" metoda GetCustomer. W wyniku otrzymujemy obiekt klasy Customer, która musi zostać przesłany do proxy. W tym celu obiekt musi zostać zserializowany czyli zapisany do strumienia i wysłany przez sieć.

I tutaj jest pies pogrzebany. Jeśli zostanie użyta serializacja XML'owa czyli taka, która "zamieni" obiekt na dokument XML to do proxy zostanie wysłana wartość zwrócona przez getter właściwości czyli coś innego niż wartość, która została ustawiona przy pomocy settera np.: jeśli do ustawienia właściwości StringValue użyjemy łańcucha "aaa" to w dokumencie XML zostanie zapisana wartość "Ala ma kota".

Po odebraniu odpowiedzi proxy spróbuje odtworzyć obiekt na podstawie odebranego dokumentu XML. Do zainicjowania właściwości StringValue użyje wartości z dokumentu czyli łańcucha "Ala ma kota". Teraz jeśli odczytamy właściwości StringValue to dostaniemy null ponieważ kod w getterze nie rozpozna łańcucha "Ala ma kota". Jeśli użyjemy serializacji binarnej problem nie wystąpi ponieważ w strumieniu zostanie zapisana wartość prywatnego pola _StringValue. Jeśli chcemy pozostać przy serializacji XML'owej to należy trochę zmodyfikować kod. Właściwość StringValue powinna być tylko do odczytu. Należy również stworzyć dodatkową właściwość do ustawiania danych.

public string StringValueForSerialization
{
  get; set;
}

public string StringValue
{
  get
  {
    if(StringValueForSerialization == "aaa")
      return "Ala ma kota";
    else if(StringValueForSerialization == "bbb")
      return "Kot ma Alę";
    ...
    else
      return null;
  }
}