Notki po polsku

Ncqrs ciąg dalszy

W poprzedniej notce powiedziałem wiele dobrego na temat Ncqrs. Aby być fair, tym razem chciałbym się skupić na kilku mankamentach, które udało mi się zauważyć podczas zabawy z tym frameworkiem.

Ncqrs nie jest jeszcze gotowy do wdrożenia produkcyjnego out-of-the-box. Nie ma co udawać. Biblioteka potrzebuje jeszcze trochę czasu, aby dojrzeć. Z drugiej strony, jeśli komuś zależałoby na jej funkcjonalności i bardzo chciał jej użyć, zawsze może zostać commiterem i poprawić te i owe braki. Na obecnym etapie, do pracy z Ncqrs jest wymagana bardzo dobra znajomość jej wewnętrznych mechanizmów, co czyni ją niepraktyczną w standardowych zespołach deweloperskich (poza R&D).

Serce Ncqrs, czyli definicja korzenia agregatu, jest zbudowane w oparciu o dziedziczenie — klasy aplikacji dziedziczą z frameworkowych klas bazowych. To dobre na początek, jednak wcześniej czy później przychodzi czas, kiedy trzeba przejść na model POCO. Jest on nie tylko bardziej przyjazny dla dewelopera aplikacji, ale także dla twórcy rozszerzeń.

W Ncqrs w tym momencie brakuje możliwości definiowania encji zawieranych w agregacie (poza korzeniem oczywiście). Z mojego doświadczenia wynika, że takie encje to raczej rzadkość, jednak się zdarzają. Problem w tym, że kiedy się już zdarzają, to są bardzo potrzebne.

Nie pisałem tego wszystkiego ot tak sobie. Jeśli jesteś pasjonatem oprogramowania, masz trochę wolnego czasu i chciałbyś pobawić się z naprawdę ciekawymi problemami, Ncqrs czeka na Ciebie! Pracy jest dużo, a te trzy problemy, które wymieniłem w tym poście, to tylko wierzchołek góry lodowej.

VN:F [1.9.13_1145]
Rating: 0.0/5 (0 votes cast)

Ncqrs

W ciągu ostatnich dwóch tygodni moją uwagę przykuł na dobre nowy framework open source — Ncqrs (witryna CodePlex Ncqrs znajduje się tutaj). Jak sugeruje nazwa, Ncqrs służy do budowy systemów w oparciu o wzorzec architektoniczny Command-Query Responsibility Separation (CQRS). To, czego nazwa nie mówi, to fakt, że Ncqrs narzuca pewną specyficzną implementację wspomnianego wzorca, a mianowicie tę opartą o technikę Event Sourcing. Na podstawie posta Grega Younga można by się czepiać, że nazwa Ncqrs nie jest zbyt trafna, ale odłóżmy kwestie nomenklatury na bok. Czym jest Ncqrs i jak to się stało, że mnie tak zafascynował?

Zasada działania

Ncqrs jest całościowym rozwiązaniem służącym do budowy systemów opartych o silny model domeny, którego stan jest przechowywany za pomocą strumienia zdarzeń. Poniższy diagram prezentuje workflow dla pojedynczego przypadku użycia w Ncqrs.

Komendy

Punktem wejścia do Ncqrs są komendy. Są to obiekty, który reprezentują żądania wykonania pewnej operacji na modelu domeny. Komendy są mapowane na operacje za pomocą rozbudowanego rozszerzalnego mechanizmu. Najprostsza implementacja mappera opiera się na dwóch atrybutach, które określają, czy dana komenda ma wykonywać metodę istniejącego obiektu (w nomenklaturze Ncqrs — korzenia agregatu), czy też tworzyć nowy obiekt. Properties komendy są mapowane (na podstawie nazwy) do parametrów wybranej metody lub konstruktora. Ostatecznie, (jeśli to konieczne) z magazynu danych podnoszony jest odpowiedni obiekt i wykonywana jest odpowiednia metoda (lub konstruktor).

Operacje biznesowe

Operacje i konstruktory obiektów są w Ncqrs wywoływane tylko za pośrednictwem komend. Ich jedynym zadaniem jest wykonanie logiki biznesowej. Nie mogą one bezpośrednio modyfikować stanu obiektu. Zamiast tego, dozwolonym mechanizm modyfikacji stanu jest zgłaszanie zdarzeń. Operacje biznesowe mogą także komunikować się ze światem zewnętrznym.

Stosowanie zdarzeń

Jak już wspomniałem na wstępie, zdarzenia są sposobem przechowywania stanu obiektów w Ncqrs. Operacja biznesowa może zgłosić jedno lub więcej zdarzeń. Dla każdego z nich framework wyszukuje odpowiedniej metody je przetwarzającej. Mechanizm ten jest oczywiście rozszerzalny, a out-of-the-box Ncqrs zapewnia dwa sposoby wiązania zdarzenia z metodą obiektu biznesowego przeznaczoną do jego obsługi: za pomocą konwencji oraz za pomocą atrybutów. Ncqrs wywołuje znalezioną metodę przekazując jej zgłoszone zdarzenie.

Przetwarzanie zdarzeń

Metoda przetwarzająca zdarzenie modyfikuje stan obiektu na podstawie danych przekazanych zdarzeniu. Tylko tyle i aż tyle. Metoda ta nie powinna zawierać żadnej logiki biznesowej (warunkowej), ani mieć jakichkolwiek skutków ubocznych (komunikacja z innymi systemami itp.).

Zapewne chcielibyście zapytać po co tyle komplikacji? Dlaczego operacja biznesowa nie może zmodyfikować stanu? Odpowiedź jest prosta. Ponieważ stan obiektów jest reprezentowany jako strumień zdarzeń przez nie wygenerowany, aby odtworzyć obiekt niezbędne jest stworzenie jego pustej instancji, a następnie przetworzenie (w kolejności!) wszystkich zapisanych zdarzeń — oczywiście za pomocą odpowiednich metod przetwarzających. Metody te są więc nie tylko stosowane do modyfikacji stanu podczas przetwarzania, ale także do odtwarzania tego stanu podczas podnoszenia obiektu z trwałego magazynu.

Dzięki takiemu podejściu systemy Event Sourcing (jak Ncqrs) zapewniają, za darmo, ślad audytowy, który ma gwarancję poprawności, ponieważ jest on wykorzystywany do budowy obiektów podczas normalnego działania systemu.

Publikowanie i denormalizacja zdarzeń

Jeśli wszystko do tej pory przebiegło prawidłowo, wszystkie zgłoszone zdarzenia są publikowane. Oczywiście, także w tym wypadku Ncqrs pozwala wymienić mechanizm publikacji zdarzeń. Domyślny wykorzystuje komunikację wewnątrz procesu, ale dostępny jest także taki, który wykorzystuje NServiceBus.

Publikowanie zdarzeń ma dwa cele. Po pierwsze, pozwala powiadomić zainteresowane systemy zewnętrzne o zmianach stanu naszego systemu. Polega to na eskalowaniu “lokalnych” (dotyczących naszego systemu) zdarzeń do statusu zdarzeń “globalnych” (mających znaczenia dla całego środowiska systemów). Stąd już tylko jeden krok do pełnej Event Driven Architecture (EDA).

Drugim celem publikowania zdarzeń jest tzw. denormalizacja, czyli aktualizacja podsystemu obsługi zapytań. Jaki podsystem? O co chodzi? Dokładny opis zagadnienia CQRS znajduej się tutaj. W tym miejscu wspomnę tylko, że systemy CQRS wykorzystują zwykle dwa osobne magazyny danych dla przetwarzania komend oraz do realizacji zapytań. Do synchronizacji tego drugiego magazynu danych wykorzystywane są właśnie denormalizatory zdarzeń. Proces denormalizacji polega na wykonaniu w bazie danych dla zapytań modyfikacji, które wynikają z opublikowanego zdarzenia. Skąd taka nazwa? Otóż zdarzenia stanowią znormalizowaną (pozbawioną redundancji) postać danych. W magazynie dla zapytań zaś, te same dane mogą mieć wiele reprezentacji, ponieważ nadrzędnym celem jest optymalizacja czasu realizacji zapytań.

Dlaczego to może działać?

Jest kilka powodów, które sprawiają, że (pozornie) szalona idea reprezentacji stanu obiektów jako ciągu zdarzeń może działać w praktyce. Oto kilka z nich:

  • zapewnia darmowy, gwarantowany, ślad audytowy
  • możliwość wykonywania tzw. snapshot’ów (czyli pełnych zrzutów zserializowanego obiektu biznesowego) co N zdarzeń. Dzięki temu odtwarzanie obiektu wymaga jedynie przetworzenia zdarzeń, które nastąpiły po ostatnim snapthot’cie
  • w klasycznym DDD (z użyciem O/RM) podczas podnoszenia obiektu z bazy danych tak naprawdę pobierane jest wiele wierszy danych (np. za pomocą podzapytań i złączeń). W przypadku Event Sourcingu pobieranych jest kilka wierszy przechowujących zdarzenia. Oba podejścia mają więc podobną złożoność na poziomie bazy danych.

Zapraszam Was do zabawy z Ncqrs. Postaram się odpowiedzieć na wszystkie Wasze pytania.

VN:F [1.9.13_1145]
Rating: 0.0/5 (0 votes cast)

Windows Communication Foundation 4.0

Druga część mojej sobotniej prezentacji dotyczyła nowości w Windows Communication Foundation. Aby zmieścić się w założonych piętnastu minutach, spośród wielu ciekawostek o których można poczytać np. u Nicholasa Allena, wybrałem dwie:

  • RoutingService
  • WS-Discovery

Dlaczego akurat te dwie? Otóż dlatego, że udało mi się wymyślić jeden wspólny temat, pod szyldem którego mógłbym je prezentować — usługę Event Brokera. Jeśli ktoś nie kojarzy tego wzorca, Event Broker to pośrednik, który forwarduje informacje o zdarzeniach pochodzące od nadawcy (źródła) do dowolnej liczby odbiorców. Celem tego wzorca jest zmniejszenie powiązania miedzy nadawcą i odbiorcami.

Projekt

Poniższa prezentacja (6 slajdów) pokazuje sposób działania Event Brokera. Pozostaje jedynie mapowanie poszczególnych funkcji na dostępne technologie. Dzięki nowemu WCF, wszystko czego potrzebujemy znajduje się już na naszym komputerze.

Funkcjonalność rejestracji subskrybentów (otrzymujących komunikaty) w Event Brokerze zapewnia nam obsługa WS-Discovery. Filtrowanie i routowanie przychodzących komunikatów do zainteresowanych odbiorców to funkcje nowego, wbudowanego w WCF, RoutingService’u. Wszystko, co musimy zrobić własnoręcznie to sprzęgnąć te dwie technologie tak, aby reakcja na ogłoszenia WS-Discovery powodowała odpowiednią modyfikację tablicy routingu.

Routing

RoutingService to gotowa do użycia usługa dostarczana wraz z nowym WCF.  Jej użycie wymaga trzech prostych kroków: stworzenia nowego hosta, skonfigurowania rodzaju routingu oraz podpięcia odpowiedniego zachowania. W kodzie wygląda to tak:

var routerServiceHost = new ServiceHost(typeof (RoutingService));
routerServiceHost.AddServiceEndpoint(typeof (ISimplexDatagramRouter), new BasicHttpBinding(), RouterAddress);
routerServiceHost.Description.Behaviors.Add(new RoutingBehavior(subscriptionManager.RoutingConfiguration));

Konfiguracja rodzaju routingu polega na utworzeniu endpoint’u odpowiedniego typu. Ja wykorzytsałem ISimplexDatagramRouter ponieważ potrzebuje przekazywać pakiety w jednym kierunku. Możliwy jest jednak także routing komunikacji request-response (z tym, że raczej już bez multicast’u).

Do konstruktora RoutingBehavior przekazujemy obiekt RoutingConfiguration, który m.in. zawiera tablicę routingu. Tablica ta może być modyfikowana z zewnątrz w dowolnym momencie, a dokonane zmiany zostaną uwzględnione podczas routowania następnego komunikatu. Kod modyfikujący tablicę wygląda np. tak:

var contract = ContractDescription.GetContract(typeof (ISimplexDatagramRouter));
var subscriber = new ServiceEndpoint(contract, new BasicHttpBinding(), address);
RoutingConfiguration.FilterTable.Add(new ActionMessageFilter(actions), new List<ServiceEndpoint> {subscriber});

WS-Discovery

Mój Event Broker wykorzystuje protokół WS-Discovery poprzez nasłuchiwanie na ogłoszenia (announcements) o pojawieniu się lub zniknięciu usług. Aby dla danej usługi WCF włączyć wysyłanie tych ogłoszeń, wystarczy zmodyfikować jej konfiguracje według poniższego wzorca:

<services>
  <service behaviorConfiguration="discoveryBehavior"
            name="Subscriber.SayHelloService">
    <endpoint name="udpDiscoveryEpt" kind="udpDiscoveryEndpoint" />
  </service>
</services>
<behaviors>
  <serviceBehaviors>
    <behavior name="discoveryBehavior">
      <serviceMetadata/>
      <serviceDiscovery>
        <announcementEndpoints>
          <endpoint kind="udpAnnouncementEndpoint" />
        </announcementEndpoints>
      </serviceDiscovery>
    </behavior>
  </serviceBehaviors>
</behaviors>

Konieczne jest dodanie nowego endpointu oraz dodanie do usługi odpowiedniego zachowania. W kodzie nie są potrzebne żadne zmiany. Modyfikacja ta sprawi, że subskrybenci będą wysyłać ogłoszenie w momencie przejścia w tryb on-line. Kolejnym krokiem jest dodanie do Event Brokera kodu, który będzie nasłuchiwał na te ogłoszenia i, zgodnie z ich treścią, modyfikował tablicę routingu. Funkcjonalność tą realizuje poniższy fragment:

var subscriptionListener = new SubscriptionListener(subscriptionManager);
var announcementService = new AnnouncementService();

announcementService.OnlineAnnouncementReceived += (s, a) => subscriptionListener.Subscribe(a);
announcementService.OfflineAnnouncementReceived += (s, a) => subscriptionListener.Unsubscribe(a);

var announcementServiceHost = new ServiceHost(announcementService);
announcementServiceHost.AddServiceEndpoint(new UdpAnnouncementEndpoint());

Jak widać, usługa WS-Discovery oferuje dwa eventy, pod które możemy podpiąć się z własnym kodem. Jest to bardzo wygodny sposób pracy.

Podsumowanie

Zapraszam do ściągnięcia całego przykładu z galerii MSDN. Mam nadzieję, że powyższy post pozwoli Wam łatwiej zrozumieć kod. Specjalnie nie omawiałem klasy obsługującej parsowanie WSDL-a, która jest niezbędna w ostatecznym rozwiązaniu, jednak nie jest ważna z punktu widzenia funkcjonalności Event Brokera. WCF 4 — polecam każdemu!

VN:F [1.9.13_1145]
Rating: 4.0/5 (1 vote cast)

Entity Framework 4 a NHibernate

W minioną sobotę miałem przyjemność uczestniczyć w krakowskiej edycji Visual Studio Community Launch. Co prawda, jako jeden z organizatorów, nie jestem najlepszą osobą do obiektywnej oceny tego wydarzenia, ale moim zdaniem było super.

Podczas VSCL miałem okazję sprawdzić się także w roli prelegenta, prowadząc dwie piętnastominutowe mikroprezentacje dotyczące Entity Framework 4 oraz Windows Communication Foundation 4. Przykłady kodu dla obu prezentacji umieściłem na MSDN Code Gallery odpowiednio tutaj i tutaj. Zapewne duża część z Was nie była na konferencji, dlatego postanowiłem owe przykłady omówić tutaj, na blogu. Dziś – EF4.

W obu przypadkach starałem się wpleść prezentację nowych funkcji danej technologii w jakąś większą historię. Dla Entity Framework tą większą historią jest możliwość współdzielenia (części) modelu z aplikacją napisaną w NHibernate.

Design

Solution wygląda tak:

Znajdują się w nim dwie aplikacje: obsługa zamówień (Orders) oraz bank (Accounts). Obie aplikacje składają się z prostego programu demonstrującego działanie, modelu domeny (Model) oraz obsługi persystencji (DataAccess). Współdzielony fragment to Parties – projekt niezależny od technologii persystencji (bez referencji do NHibernate lub EF) zawierający implementację podmiotów (osób fizycznych i organizacji).

POCO

Jednym z celów nowej wersji Entity Framework było wsparcie dla budowy modeli POCO (Plain Old CLR Object). Dlaczego jest to takie ważne? Ponieważ właśnie obsługa POCO pozwala wykorzystywać w EF klasy, które nie były napisane konkretnie z myślą o nim oraz, patrząc z drugiej strony, pozwala wykorzystać klasy napisane specjalnie dla EF w innych kontekstach. POCO daje nam komfort braku zależności od frameworku ORM na poziomie modelu domeny – coś, czego nie sposób przecenić. Dla przykładu, klasa Party reprezentująca podmiot wygląda tak:

public class Party
{
   public virtual int Id { get; protected set; }
   public virtual Address Address { get; protected set; }

   protected Party(Address address)
   {
      Address = address;
   }

   protected Party()
   {
   }
}

Lazy Loading

Leniwe ładowanie (lazy loading) jest kolejnym wielkim nieobecnym z pierwszej wersji EF, który doczekał się implementacji w wersji drugiej (czwartej). Jest ono jednak domyślnie wyłączone. Aby je włączyć, należy dodać do konstruktora kontekstu następującą linię:

ContextOptions.LazyLoadingEnabled = true;

Wszystko jednak ma swoją cenę. Ceną, którą płacimy za obsługę leniwego ładowania w EF jest konieczność „wirtualizacji” wszystkich property klas modelu. Jest to niezbędne, ponieważ EF w trakcie działania systemu generuje dynamicznie klasę dziedziczącą po naszej. Nie jest to jednak wielki problem, ponieważ NHibernate ma analogiczne wymaganie. Możemy więc spokojnie przywyknąć do myśli, że standardem dla ORM jest konieczność deklarowania wirtualnych property.

Value Objects

Value Objects są nazywane w EF „Complex Type”. Ot, taka fanaberia ludzi z Microsoft. Complex Type w EF może być modyfikowalny. Ja osobiście jednak odradzałbym to ze wszystkich. Niemodyfikowalność Value Object jest bardzo ważną cechą, m.in. ułatwiającą testowanie.

Ponieważ dla Complex Type EF nie prowadzi change tracking’u (sprawdzenie, czy należy zaktualizować bazę wykonywane jest za pomocą porównania wartości aktualnych i pobranych), nie ma sensu wirtualizacja properties CT. Znowu, jest to zgodne z tym, jak ja pracuje z NHibernate. Moje Value Objects muszą być jak najbardziej niezależne kontekstu bazodanowego. Jedyne na co się mogę (i niestety muszę) zgodzić to pusty konstruktor z widocznością protected. Zarówno EF, jaki NHibernate go wymagają. Efektem tego zestawu wymagań jest klasa Address będąca Value Objectem reprezentującym adres podmiotu:

public class Address
{
   public string Street { get; private set; }
   public string BuildingNumber { get; private set; }
   public string City { get; private set; }
   public string Country { get; private set; }

   protected Address()
   {
   }

   public Address(string street, string buildingNumber, string city, string country)
   {
      Street = street;
      BuildingNumber = buildingNumber;
      City = city;
      Country = country;
   }
}

Enkapsulacja

Enkapsulacja to zawsze dobra rzecz. Nie inaczej jest w wypadku modelu domeny. Upublicznianie getterów (a broń Boże setterów) właściwości jest prostą drogą do degradacji naszego modelu do roli prostych struktur danych. A przecież nie o to nam wszystkim chodzi. Z tego powodu bardzo się zmartwiłem po przeczytaniu na MSDN opisu wymagań dla POCO w EF. Wynika z niego (chyba, że ja źle rozumiem), że aby leniwe ładowanie działało, wszystkie właściwości klasy muszą być „public virtual”.

Na szczęście przeglądając sieć natknąłem się na wzmiankę, iż wystarczy „protected virtual”. Postanowiłem sprawdzić. Jakaż była moja radość, gdy okazało się, że wszystko działa. W kontekście enkapsulacji EF4 ma analogiczne wymagania, co NHibernate. Yupi:-)

Klasa Account jest świetnym przykładem enkapsulacji dla kolekcji: lista operacji związanych z kontem nie może być modyfikowana bezpośrednio, ale jedynie poprzez wywołania Credit lub Debit:

public class Account
{
   //...
   public decimal Balance { get; protected set; }
   protected virtual IList<Operation> Operations { get; set; }

   //...
   public virtual void Debit(decimal amount, string title)
   {
      if (Balance - amount < 0)
      {
         throw new InvalidOperationException();
      }
      var op = new Operation(-amount, title);
      Operations.Add(op);
      Balance -= amount;
   }

   public virtual void Credit(decimal amount, string title)
   {
      var op = new Operation(amount, title);
      Operations.Add(op);
      Balance += amount;
   }
}

Podsumowanie

Druga edycja Entity Framework jest o niebo lepsza od poprzedniej. Właściwie mogę powiedzieć z czystym sumieniem, że jest nawet używalna w kontekście Domain-Driven Design. Cieszy mnie to niezmiernie, bo lubię mieć wybór. Pluralizm to dobra rzecz. Czy rozważam przesiadkę z NHibernate na EF? Na pewno nie w tej wersji. Przewaga NH w przypadku bardziej skomplikowanych kwestii jest jeszcze zbyt duża. Z drugiej strony nie potrzebuję (i nie będę potrzebował) wszystkich tych kwestii związanych z obsługą architektur n-tier, w które Microsoft pakuje tyle pary.

Jeśli jednak EF będzie się rozwijać w tak szybkim tempie, jest wysoce prawdopodobne, że wersja 6.0 będzie stanowić groźną konkurencje dla NHibernate także w kontekście DDD.

VN:F [1.9.13_1145]
Rating: 3.0/5 (1 vote cast)

RavenDB – najprostsza baza danych, jaką widziałeś

RavenDB właśnie został oficjalnie opublikowany. Przez kilka ostatnich dni popołudniami i ł eksperymentowałem z tą technologią. Teraz chciałbym się z Wami podzielić moimi odczuciami.

Z początku byłem nastawiony bardzo sceptycznie — kolejna zabawka Ayende. Po Rhino DHT, Rhino PHT, Rhino Queues, Rhino ServiceBus i innych, których nie pamiętam, straciłem entuzjazm. W końcu jednak postanowiłem dać Raven’owi szanse. W końcu jest to jedyny produkt z kategorii NoSQL napisany w .NET.

Moje eksperymenty rozpocząłem od zabawy z klienckim API. Jest to coś, co wyróżnia ten produkt na tle konkurencji. Inne bazy dokumentowe albo wcale nie mają .NETowego API, albo jest ono napisane na odczepkę i po macoszemu. API klienckie Ravena jest przemyślane i dopracowane w każdej kwestii. Prawdopodobnie dlatego, że jest oparte o NHibernate. Przestawienie się z jednego na drugie było dla mnie kwestią minut: zamiast ISessionFactory — IDocumentStore, zamiast ISession — IDocumentSession.

Z naśladowaniem API NHibernate wiąże się jeszcze jedna unikatowa cecha API Ravena — implementacja wzorca Unit of Work. Oznacza to, że w ramach jednej instancji IDocumentSession każdy pobrany dokument ma dokładnie jedną reprezentację w pamięci. Jeśli pobierzemy dwukrotnie ten sam dokument, Raven automatycznie wykryje ten fakt i za drugim razem zwróci nam pobraną wcześniej instancję. Możemy więc śmiało używać operatora == na obiektach wyciągniętych z bazy.

Kolejnym miłym zaskoczeniem była dla mnie obsługa transakcji. Na poziomie protokołu HTTP Raven może przetwarzać wiele zleceń modyfikacji danych wysłanych za pomocą jednego żądania. Taka paczka jest wtedy przetwarzana transakcyjnie — albo wykonają się wszystkie modyfikacje, albo żadna. Na poziomie API klienta, Raven automatycznie łączy w paczki wszystkie modyfikacje z danej instancji IDocumentSession. Nasz unit of work jest więc także transakcją. Czy może być coś prostszego?

API Ravena wspiera także sharding, czyli technikę pozwalającą klientowi traktować wiele instancji bazy danych jako jedną wielką instancję. Co najważniejsze, wsparcie to jest zupełnie przeźroczyste dla kodu — zrealizowane jest po prostu jako inna implementacja pary interfejsów IDocumentStore oraz IDocumentSession.

Zachęcam Was wszystkich do poeksperymentowania z Ravenem. Ruch NoSQL zasługuje na odrobinę uwagi. Zrozumiałe jest dla mnie, że nie każdy od razu rzuca się na CouchDB i pisze “z palca” funkcje map-reduce w Erlangu, ale Raven to zupełnie inna para kaloszy. Raven jest napisany w .NET specjalnie dla developerów piszących w .NET! Naprawdę warto dać mu szansę.

VN:F [1.9.13_1145]
Rating: 4.5/5 (2 votes cast)