T4MVC – sposób na silną typizację w ASP.NET MVC

Dzisiaj o prostej bibliotecę dla ASP.NET MVC. Często w ASP.NET MVC musimy stosować zahardcodowane string’i, co oczywiście nie jest dobrą praktyką.

Na przykład, aby wygenerować link do danej akcji w razor, moglibyśmy:

@Html.ActionLink("Opis linku", "Add", "Persons", new { id=Model.MasterId }, null)

Za pomocą T4MVC wystarczy:

@Html.ActionLink("Opis linku", MVC.Dinners.Add(Model.MasterId))

T4MVC, jak sama nazwa wskazuje, oparty jest na szablonach T4, które generują helpery takie jak powyższy.

Kilka innych przykładów z oficjalnej dokumentacji:

// bez T4MVC <% Html.RenderPartial("DinnerForm"); %> // T4MVC <% Html.RenderPartial(MVC.Dinners.Views.DinnerForm); %> // bez T4MVC return View("InvalidOwner"); // T4MVC return View(Views.InvalidOwner);

Więcej przykładów znajdziecie tutaj.

Visual Studio 2015–Diagnostic Tools

Nie dawno pojawił się VS 2015 CTP5 -  zachęcam do ściągnięcia.

VS 2015 posiada wiele nowych narzędzi pozwalających na diagnostykę i profilowanie aplikacji. Chcę poświęcić na to kilka wpisów, zaczynając dzisiaj od Diagnostic Tools.

Oczywiście wszystkie te udogodnienia były wcześniej dostępne w formie osobnych narzędzi typu Memory czy CPU profiler. Trend jednak jest taki, że Visual Studio z każdym wydaniem zawiera więcej narzędzi wbudowanych, które kiedyś były osiągalne wyłącznie różne profilery czy Resharper.  Wciąż oczywiście jest długa droga przed VS (zwłaszcza w przypadku Resharper), ale pomysł wbudowanych profilerów bardzo podoba mi się.

Okno diagnostic tools otwiera się automatycznie po rozpoczęciu debuggowania. Jest zatem uzupełnieniem klasycznych narzędzi diagnostycznych takich jak Output czy lista ze zmiennymi (Watch, Locals itp.).  Diagnostic Tools składa się z 3 elementów: Debugger Events, zużycie pamięci oraz zużycie procesora (CPU). Po uruchomieniu, okno prezentuje się następująco:

image

Pierwsza sekcja, Debugger events pokazuje zdarzenia takie jak wywołanie Debugger.Break czy odpalanie danego breakpoint’a. Innymi słowy, mamy tam informacje, co spowodowało zatrzymanie pracy i uruchomienie trybu debuggowania. Przyjrzyjmy się oknu Debugger w momencie, gdy zostanie wyrzucony wyjątek:

image

Całą historie zdarzeń mamy na wykresie, a mianowicie:

image

Screen został zrobiony na podstawie następującego kodu:

image

 

Czerwony romb oznacza wyjątek. Fioletowy pasek reprezentuje przerwanie wykonania aplikacji z powodu wyjątku, czerwony pasek oznacza odpalenie breakpoint’u, z kolei niebieski wywołanie Debugger.Break().

Jak widzimy wielkość pasku zależy od czasu. Pierwszy breakpoint został wykonany prawie natychmiast od czasu uruchomienia aplikacji, dlatego jest bardzo mały. Następnie są uruchamiane co 2 sekundy stąd są nieco większe. Najeżdżając kursorem na pasek, dostaniemy szczegóły:

image

Wykres jest interaktywny. Klikając na zdarzeniu w oknie debugger dostaniemy informacje o nim. Na przykład, klikając na niebieskim pasku, zobaczymy w oknie debugger następujące informacje:

image

Można również zaznaczyć kilka zdarzeń jednocześnie, zaznaczając kursorem przedział czasu, który nas interesuje:

image

image

Kolejne okno pozwoli nam monitorować całkowite zużycie pamięci w aplikacji. Bardzo wygodne, bo debuggując na bieżąco widzimy, czy coś złego nie dzieje się w aplikacji. Załóżmy, że mamy następujący kod:

int a = 0; Thread.Sleep(2000); int[] data = new int[100000000]; Thread.Sleep(2000); data = null; GC.Collect(); GC.WaitForFullGCComplete(); Thread.Sleep(2000); data = new int[10000000]; Thread.Sleep(2000); Debugger.Break();

Na początku deklarujemy jedną zmienną, potem dużą tablice, następnie ją zwalniamy i alokujemy nieco mniejszą. Na wykresie zobaczymy zatem następujące dane:

image

 

Pierwsze 2 sekundy to deklaracja tylko jednej zmiennej, następnie tworzymy dużą tablicę zatem zużycie pamięci rośnie. Kolejna linia kodu usuwa tablicę z pamięci, stąd następne dwie sekundy to znów niskie zużycie pamięci. Ostatni fragment to alokacja małej tablicy i widzimy wyraźnie, że zużycie trochę wzrosło. Oczywiście jest to całkowite zużycie pamięci, a nie tylko zmiennych, które sami tworzymy.

Ostatnie okno, które chciałem pokazać to po prostu zużycie CPU:

image

Wykres, jak każdy poprzedni jest interaktywny i najeżdżając kursorem dostaniemy dodatkowe informacje.

Jak wspomniałem, nie są to żadne nowości, ale ze względu na to, że są zintegrowane z VS i tak łatwo dostępne, debugging aplikacji pozwala na bieżąco monitorować, co dzieje się z kodem.

Antywzorce w pisaniu testów

W kolejnych kilku wpisach przedstawię parę luźnych porad odnośnie pisania testów. Nie będzie to żaden przewodnik, ale moje własne spostrzeżenia nabyte podczas przeglądania kodu różnych projektów.

Zacznę od pewnego anty-wzorca, znalezionego na blogu http://watirmelon.com/:

s

Na rysunku widać skrajnie złą sytuację w systemie – liczbę testów w zależności od kategorii. Powinno być dokładnie odwrotnie (na górze testy jednostkowe, potem integracyjne, UI i ręczne).

Jeśli większość systemu jest przetestowana przez manualne testy to znaczy, że koszt produkcji jest bardzo wysoki, release’y długie,a błędy regresyjne stają się normą. W dobrze zaprojektowanej aplikacji, najwięcej powinno być testów jednostkowych, ponieważ są one najszybsze w uruchomieniu. Nie potrzebują żadnej zewnętrznej konfiguracji i każdy programista może je odpalić w lokalnym środowisku. Włączając do tego TDD\BDD są bardzo potężnym narzędziem i wyłapanie błędu na wczesnym etapie jest najłatwiejsze.

Oczywiście są miejsca w systemie, które nie mogą zostać pokryte testami jednostkowymi i tu w grę wchodzą testy integracyjne. Niestety bardzo często widzę błędne testy integracyjne. Powszechna zła praktyka to duplikowanie asercji logiki biznesowej w testach integracyjnych. Logikę biznesową powinniśmy przetestować w unit testach. W testach integracyjnych, jak sama nazwa wskazuję, zajmujemy się punktami integracji (np. dwóch serwisów ze sobą). Musimy się zatem upewnić, że odpowiednie interfejsy zostały wstrzyknięte i wywołane. Nie przejmujemy się z kolei, czy dany algorytm zwrócił wartość 10 czy 5. Dokładna wartość została już pokryta w unit testach.

Testy integracyjne wymagają czasami pewnej konfiguracji wstępnej, dlatego są mniej wygodne niż testy jednostkowe – stąd nie powinny stanowić trzonu naszego  QA.

Kolejna grupa to testy UI. Bardzo często  są nadużywane i używanie wyłącznie dlatego, że można je zaprezentować łatwo na sprint demo itp.  Musimy pamiętać, że wymagają one pełnego środowiska i uruchomienie ich jest najwolniejsze. W przypadku unit test, możemy skorzystać z pewnych narzędzi, które wykonują je za nas w tle – dając nam bardzo szybki feedback.  Naturalnie UI tests są automatyczne, dlatego dużo bardziej preferowane są niż testy ręczne. Nie powinniśmy jednak skupiać się na logice biznesowej, a wyłącznie weryfikować kluczowe punkty sytemu lub UI. Stanowią one pełne testy integracyjne i to jest ich ogromna zaleta.

Dodam jeszcze, że w wielu typach aplikacji, testy manualne praktycznie są nie potrzebne. Jeśli zespół potrzebuje kilka osób regularnie wykonujących ręczne testy, to zdecydowanie przedstawiony anty-wzorzec ma tam miejsce. Oczywiście są pewne specyficzne aplikacje (szczególne gry, wizualizacje itp), które wymagają stosunkowo dużej liczby testów manualnych. Natomiast w przypadku oprogramowania biznesowego (web, services) itp. nie powinno być już żadnych wymówek.

Z powyższych rozważań wynika, że testy jednostkowe powinny być najbardziej preferowane.  Ktoś może powiedzieć, że jeśli klasa A wywołuje klasę B, to bez mock’ow taki test stanowi tak naprawdę test integracyjny. O tym temacie napiszę za kilka wpisów, ale przedstawiając powyższy anty-wzorzec, mam na myśli, że testy jednostkowe to wszystkie te, które mogą być wykonane w izolacji (nie wymagają dostępu do bazy danych, web service itp.).

Warto zwrócić uwagę, że programowanie defensywne jest również bardzo skuteczne i potrafi wykryć błędy przed testami jednostkowymi. W środowisku .NET mam na myśli głównie Code Contracts. Dla chętnych polecam przejrzenie blogu ponieważ kilka razy o nich już pisałem.

nServiceBus–Gateways, część VII

Dzisiaj o implementacji wzorca gateway w NSB. Wzorzec bramki jest bardzo znany, ale w programowaniu rozproszonym ma swoją konkretną implementację. Bardzo często jest nadużywany i dlatego kluczowe jest jak działa jego implementacja w NSB oraz kiedy należy z niej korzystać.

Brama w NSB to punkt dostępowy do systemu, który jest wykorzystywany, gdy dwa węzły znajdują się poza LAN\VPN.  Innymi słowy, jeśli dwa serwisy są publiczne (połączone Internetem) wtedy nie możemy skorzystać ze standardowego mechanizmu kolejek Windows i musimy zastosować np. protokół HTTP lub HTTPS. Bramki nie mają sensu w lokalnych sieciach, gdzie najlepszą technologią są czyste kolejki – zapewniają wydajność i gwarancje dostarczenia wiadomości, nawet jak sieć będzie tymczasowo niedostępna.

Załóżmy, że mamy dwa serwisy NodeA oraz NodeB, które umieszczone są w kompletnie dwóch różnych lokalizacjach. Bramki będą stanowić zwykłe handlery i mogą wyglądać następująco:

public class SendEmailHandler : IHandleMessages<Email> { public IBus Bus { get; set; } public void Handle(Email email) { // jakis kod Bus.Reply<EmailSent>(m=> { m.Id = email.Id; }); }

W tym momencie kod jeszcze niczym nie różni się od klasycznego handler’a. To, że handler będzie wyeksponowany jako bramka, zależy wyłącznie od konfiguracji:

public class EndpointConfig : IConfigureThisEndpoint, AsA_Server { public void Customize(BusConfiguration configuration) { configuration.UsePersistence<RavenDb>(); configuration.EnableFeature<Gateway>(); } }

EnableFeature<Gateway> to pierwsza zmiana, jaką musimy dokonać. Kolejna konfiguracja to plik .config, gdzie musimy zawrzeć następującą deklarację:

<GatewayConfig TransactionTimeout="00:10:00"> <Channels> <Channel Address="http://localhost:8081/NodeA/" ChannelType="Http" Default="true" /> </Channels> </GatewayConfig>

Za pomocą tego adresu, będziemy mogli wysyłać wiadomości do bramki NodeA. Analogicznie sytuacja wygląda oczywiście z NodeB.

Następnie z dowolnego miejsca możemy wysyłać wiadomości do bramek za pomocą SendToSites:

Bus.SendToSites(new[] { "NodeA", "NodeB" }, new Email { ID=1, Content="test" });

Niezbędna jest również konfiguracja danego węzła, który będzie komunikował się z innymi bramkami:

<GatewayConfig> <Sites> <Site Key="NodeA" Address="http://localhost:8081/NodeA/" ChannelType="Http" /> <Site Key="NodeB" Address="http://localhost:8081/NodeB/" ChannelType="Http" LegacyMode="false" /> </Sites> <Channels> <Channel Address="http://localhost:8081/Master/" ChannelType="Http" /> </Channels> </GatewayConfig>

Powyższa sekcja z pliku konfiguracyjnego, definiuje adres danej bramki (Channel) jak i innych bramek za pomocą sekcji <Sites>. Innymi słowy, będziemy mieli w systemie węzły Master, NodeA, NodeB:

image

Oczywiście również należy pamiętać o EnableFeature<Gateway> dla węzła master.

Wiadomości są zatem wysyłane za pomocą HTTP\HTTPS. Załóżmy, że master chce wysłać komendę do NodeA. Na początku zostanie ona umieszczona w kolejce jak to w klasycznych systemach kolejkowych. Następna bramka zdejmie wiadomość z kolejki i wyśle ją przez HTTP\HTTPS do bramki NodeA. Bramka NodeA przyjmie połącznie HTTP\HTTP, odtworzy wiadomość i policzy hash danej wiadomości.  W kolejnym etpaie hash zostanie odesłany do master i jeśli zgadzają się, tzn. jeśli hash wiadomości wysłanej i odebranej są takie same, wtedy NodeA umieści wiadomość w kolejce wiadomości przychodzących. Dalsze etapy są już standardowe czyli w momencie, gdy tylko będzie to możliwe, wiadomość zostanie przetworzona przez handler. W przypadku, gdy hash’e nie będą zgadzały się, proces będzie rozpoczęty od nowa.

Podsumowując jeszcze raz, jeśli mamy VPN\LAN to możemy zapomnieć o bramkach i korzystać z czystych kolejek. NSB dostarcza jednak bardzo łatwo konfigurację i przełączenie się między zwykłym handlerem, a bramką jest bardzo szybkie. Implementacja gwarantuje, że wszelkie duplikaty wiadomości zostaną rozpoznane i usunięte. W przypadku błędów w przetwarzaniu wiadomości również zostanie zastosowany mechanizm automatycznego powtarzania opisany w poprzednich już postach.

Quartz.NET–planowanie zadań

Czasami potrzeba nam prostego narzędzia, które będzie wykonywało jakieś zadania w określonych ramach czasowych. Można użyć prostego Timera z .NET Framework, ale ma on dość ograniczone możliwości. Na przykład, stan zadań nie może być zapisany w bazie danych. Dla bardzo zaawansowanych rozwiązań, zwykle mamy inną architekturę, na przykład opartą na kolejkach. W takich sytuacjach, zwykle poszczególne technologie posiadają swoje mechanizmy, tak jak nServiceBus o który już wiele razy pisałem.

Dzisiaj jednak chciałbym przedstawić Quartz.NET – lekka biblioteka, która nada się do do prostych przypadków, dla których jednak czysty, standardowy timer ma zbyt małe możliwości.

Standardowo instalujemy pakiet z NuGet:

image

API jest bardzo proste. Standardowy szablon  wygląda następująco:

try { IScheduler scheduler = StdSchedulerFactory.GetDefaultScheduler(); scheduler.Start(); Thread.Sleep(1000000); scheduler.Shutdown(); } catch (SchedulerException ex) { Console.WriteLine(ex); }

Koniecznie należy wywołać Shutdown na koniec.  W przeciwnym wypadku, wciąż w aplikacji istniałyby wątki przeznaczone do wykonywania zadań.  Same zadanie definiuje się poprzez implementację interfejsu IJob:

class PrintTextJob : IJob { public void Execute(IJobExecutionContext context) { Console.WriteLine("Test: {0}",DateTime.Now); } }

Następnie należy ją dodać do scheduler’a:

IJobDetail job = JobBuilder.Create<PrintTextJob>().Build(); ITrigger trigger = TriggerBuilder.Create(). StartNow(). WithSimpleSchedule(x => x.WithIntervalInSeconds(10).RepeatForever()).Build(); scheduler.ScheduleJob(job, trigger);

Definicja Triggera jest tutaj chyba najważniejsza. Szczegóły znajdują się oczywiście w dokumentacji, ale istnieje wiele sposób zdefiniowania kiedy zadanie ma wykonać się. Do dyspozycji mamy nawet cron expression:

TriggerBuilder.Create().WithCronSchedule("0 42 10 * * ?")

Można również zdefiniować zachowanie scheduler’a za pomocą pliku konfiguracyjnego:

quartz.scheduler.instanceName = MyScheduler quartz.threadPool.threadCount = 3 quartz.jobStore.type = Quartz.Simpl.RAMJobStore, Quartz

Jak widać, można określić liczbę wątków oraz typ bazy. Domyślnie jest to in-memory, ale nic nie stoi na przeszkodzie, aby zapisać wyzwalacze czy zadania w innym typie bazy.

Quartz wspiera wykonywanie logów i możemy je zdefiniować za pomocą:

Common.Logging.LogManager.Adapter = new Common.Logging.Simple.ConsoleOutLoggerFactoryAdapter {Level = Common.Logging.LogLevel.Info};

Możemy również śledzić wszelkie wykonywane operacje np. poprzez implementacje interfejsu ISchedulerListener:

public interface ISchedulerListener { void JobScheduled(Trigger trigger); void JobUnscheduled(string triggerName, string triggerGroup); void TriggerFinalized(Trigger trigger); void TriggersPaused(string triggerName, string triggerGroup); void TriggersResumed(string triggerName, string triggerGroup); void JobsPaused(string jobName, string jobGroup); void JobsResumed(string jobName, string jobGroup); void SchedulerError(string msg, SchedulerException cause); void SchedulerShutdown(); }

Następnie zaimplementowany listener należy dodać, a po wszystkim oczywiście usunąć:

scheduler.ListenerManager.AddSchedulerListener(mySchedListener); scheduler.ListenerManager.RemoveSchedulerListener(mySchedListener);

Analogicznie sprawa wygląda z ITriggerListener oraz IJobListener:

public interface ITriggerListener { string Name { get; } void TriggerFired(ITrigger trigger, IJobExecutionContext context); bool VetoJobExecution(ITrigger trigger, IJobExecutionContext context); void TriggerMisfired(ITrigger trigger); void TriggerComplete(ITrigger trigger, IJobExecutionContext context, int triggerInstructionCode); } public interface IJobListener { string Name { get; } void JobToBeExecuted(IJobExecutionContext context); void JobExecutionVetoed(IJobExecutionContext context); void JobWasExecuted(IJobExecutionContext context, JobExecutionException jobException); }

Następnie wystarczy wywołać scheduler.ListenerManager.AddJobListener.

CodeReview: Struktura readonly a wydajność

Kiedyś na blogu wyjaśniłem jak działa readonly, gdy jest wywoływany po za konstruktorem. Posłużyłem się następującym przykładem:

internal class Program { public static readonly RiskInfo RiskInfo = new RiskInfo(); private static void Main(string[] args) { Console.WriteLine(RiskInfo.CalculateScore()); Console.WriteLine(RiskInfo.CalculateScore()); Console.WriteLine(RiskInfo.CalculateScore()); } }

Gdzie RiskInfo to:

struct RiskInfo { public int Condition { get; set; } public double Score { get; set; } public double CalculateScore() { Score = Score + 1; return Score; } }

Okazuje się, że wynik na ekranie za każdym razem wynosi 1:

image

Gdybyśmy usunęli modyfikator readonly, albo przesunęli logię do konstruktora, wtedy zobaczylibyśmy 1,2,3.

Oczywiście, każda struktura powinna być immutable, zatem w dobrze zaprojektowanym kodzie, nie musimy martwić o efekty uboczne wynikające z wewnętrznej implementacji języka. 

Dzisiaj chciałbym jednak przestrzec przed pułapką, która wynika z powyższych uwag. Za każdym razem, gdy odwołujemy się do takiego pola, kopiujemy wartość. Wyobraźmy sobie, że mamy następującą strukturę:

struct RiskInfo { private double _factor1; private double _factor2; private double _factor3; private double _factor4; private double _factor5; private double _factor6; private double _factor7; private double _factor8; public double Factor1 { get { return _factor1; } } }

Nie ważne co dokładnie w niej mamy. Chodzi mi o to,  że struktura może zawierać wiele pól, a zatem za każdym razem będą one kopiowane.

Napiszmy prosty benchmark:

internal class Program { private static readonly RiskInfo _riskInfo = new RiskInfo(); private static void Main(string[] args) { const int n = 100000000; Stopwatch stopwatch = Stopwatch.StartNew(); for(int i=0;i<n;i++) { double factor = _riskInfo.Factor1; } Console.WriteLine(stopwatch.ElapsedMilliseconds); } }

Wynik:

image

Następnie analogiczna konstrukcja, ale bez modyfikatora readonly:

internal class Program { private static RiskInfo _riskInfo = new RiskInfo(); private static void Main(string[] args) { const int n = 100000000; Stopwatch stopwatch = Stopwatch.StartNew(); for(int i=0;i<n;i++) { double factor = _riskInfo.Factor1; } Console.WriteLine(stopwatch.ElapsedMilliseconds); } }

Wynik:

image

Widzimy, że różnica jest ogromna i nie nazwałbym tego mikro-optymalizacją. Nic dziwnego, wywołanie metody (właściwości) powoduje przekopiowanie całego obiektu. W konstruktorze jak wspomniałem wcześniej, nie doszłoby do tego problemu. Wywoływanie  metod lub właściwości na typach prostych, tylko do odczytu, po za konstruktorem jest równoznaczne z wywołaniem właściwości zwracającej typ prosty (czyli dochodzi do kopiowania).

Przyjrzyjmy się również IL, aby zrozumieć jak to działa. Tak naprawdę tworzona jest tymczasowa zmienna i w c# mogłoby to wyglądać następująco:

RiskInfo temp = _riskInfo; double factor1 = temp.Factor1;

W przypadku, gdy pole nie jest readonly, wywołanie wygląda po prostu:

double factor1 = _riskInfo.Factor1;

IL dla readonly:

.method private hidebysig static void Main ( string[] args ) cil managed { // Method begins at RVA 0x2058 // Code size 48 (0x30) .maxstack 2 .entrypoint .locals init ( [0] class [System]System.Diagnostics.Stopwatch stopwatch, [1] int32 i, [2] valuetype RiskInfo CS$0$0000 ) IL_0000: call class [System]System.Diagnostics.Stopwatch [System]System.Diagnostics.Stopwatch::StartNew() IL_0005: stloc.0 IL_0006: ldc.i4.0 IL_0007: stloc.1 IL_0008: br.s IL_001c // loop start (head: IL_001c) IL_000a: ldsfld valuetype RiskInfo Program::_riskInfo IL_000f: stloc.2 IL_0010: ldloca.s CS$0$0000 IL_0012: call instance float64 RiskInfo::get_Factor1() IL_0017: pop IL_0018: ldloc.1 IL_0019: ldc.i4.1 IL_001a: add IL_001b: stloc.1 IL_001c: ldloc.1 IL_001d: ldc.i4 100000000 IL_0022: blt.s IL_000a // end loop IL_0024: ldloc.0 IL_0025: callvirt instance int64 [System]System.Diagnostics.Stopwatch::get_ElapsedMilliseconds() IL_002a: call void [mscorlib]System.Console::WriteLine(int64) IL_002f: ret } // end of method Program::Main

IL bez readonly:

.method private hidebysig static void Main ( string[] args ) cil managed { // Method begins at RVA 0x2058 // Code size 45 (0x2d) .maxstack 2 .entrypoint .locals init ( [0] class [System]System.Diagnostics.Stopwatch stopwatch, [1] int32 i ) IL_0000: call class [System]System.Diagnostics.Stopwatch [System]System.Diagnostics.Stopwatch::StartNew() IL_0005: stloc.0 IL_0006: ldc.i4.0 IL_0007: stloc.1 IL_0008: br.s IL_0019 // loop start (head: IL_0019) IL_000a: ldsflda valuetype RiskInfo Program::_riskInfo IL_000f: call instance float64 RiskInfo::get_Factor1() IL_0014: pop IL_0015: ldloc.1 IL_0016: ldc.i4.1 IL_0017: add IL_0018: stloc.1 IL_0019: ldloc.1 IL_001a: ldc.i4 100000000 IL_001f: blt.s IL_000a // end loop IL_0021: ldloc.0 IL_0022: callvirt instance int64 [System]System.Diagnostics.Stopwatch::get_ElapsedMilliseconds() IL_0027: call void [mscorlib]System.Console::WriteLine(int64) IL_002c: ret } // end of method Program::Main

Widzimy, że w przypadku readonly jest tworzona tymczasowa zmienna CS$0$0000, do której kopiujemy wartość w każdej iteracji. Jeszcze raz podkreślam, że problem występuje wyłącznie przy wywoływaniu metod, co niestety ma miejsce w przypadku typów immutable. Nigdy nie deklarujemy publicznych pól (aby uniemożliwić ich modyfikację), więc jedynym sposobem jest wywołanie właściwości, która jest metodą.

Kowariancja tablic a wydajność

Dziś kolejna ciekawostka z C#. Wiemy, że tablice w c# wspierają kowariancje. Oznacza to, że możemy:

string[]names=new string[5]; object[] objectNames = names; objectNames[0]="hello";

Niestety ma to pewną pułapkę. Co stanie się, gdy będziemy chcieli przypisać niepoprawny typ?

string[] names = new string[5]; object[] objectNames = names; objectNames[0] = 5;

Sytuacja zostanie wykryta przez CLR i wyrzucony zostanie wyjątek ArrayTypeMismatchException. Gdyby nie ten wyjątek, doszłoby do poważnego zamieszania w pamięci, więc jest to absolutnie potrzebne. Co to jednak oznacza? Za każdym razem, gdy zapisujemy dane w tablicy, musi istnieć warunek sprawdzający, czy nie chcemy zapisać niepoprawny typ. Nie ma to znaczenia dla pojedynczych wywołań, ale w pętli (np. while dla wzorca producent\konsument) może to spowodować zauważalne problemy.

Jakie jest rozwiązanie? Najprościej używać value type, które oczywiście nigdy nie spowodują podobnych problemów, stąd CLR nie musi tego za każdym razem sprawdzać. Każdy typ referencyjny może w końcu zostać opakowany w typ prosty. Przykład prostego wrappera:

public struct ReferenceValueWrapper<T> where T:class { public T Value; }

Porównajmy więc wydajność następujących przykładów:

private static void Test1(int arraySize) { Base[] data = new Base[arraySize]; Child child=new Child(); Stopwatch stopwatch = Stopwatch.StartNew(); for (int i = 0; i < arraySize; i++) { data[i] = child; } Console.WriteLine(stopwatch.ElapsedMilliseconds); } private static void Test2(int arraySize) { ReferenceValueWrapper<Base>[] data = new ReferenceValueWrapper<Base>[arraySize]; Child child = new Child(); Stopwatch stopwatch = Stopwatch.StartNew(); for (int i = 0; i < arraySize; i++) { data[i] = new ReferenceValueWrapper<Base>() {Value = child}; } Console.WriteLine(stopwatch.ElapsedMilliseconds); }

Tablica zawiera typ bazowy, a przypisujemy poszczególnym elementom obiekt potomny. Wynik:

image

Różnica czterokrotna… Nie zawsze jednak wydajność jest dużo gorsza. Modyfikując trochę powyższy przykład dostaniemy:

private static void Test1(int arraySize) { Base[] data = new Child[arraySize]; Child child=new Child(); Stopwatch stopwatch = Stopwatch.StartNew(); for (int i = 0; i < arraySize; i++) { data[i] = child; } Console.WriteLine(stopwatch.ElapsedMilliseconds); }

Wynik:

image

Wniosek z tego taki, że w powyższym przykładzie nie został wygenerowany warunek sprawdzający i wyrzucający wyjątek. Mając tablicę z Child, naturalne jest, że nie dojdzie do konfliktu typów.

SpecFlow: Uruchamianie testów za pomocą SpecRun

W poprzednich postach, używałem domyślnego runner’a dla danego framework’u.  Standardowo SpecFlow może być zintegrowany np.  z nUnit lub Mbunit. Jeśli tylko to możliwe, polecam zainstalowanie SpecRun, bo jak zaraz pokażę, jest dużo wygodniejszy w przypadku BDD.

Na początku sprawdźmy, co jest wygenerowane dla poniższego testu:

Feature: NewPost Jakis opis tutaj... Scenario: Create a new post Given I have logged into CMS When I press the create a post button Then article should be created.

Code-behind:

// ------------------------------------------------------------------------------ // <auto-generated> // This code was generated by SpecFlow (http://www.specflow.org/). // SpecFlow Version:1.9.0.77 // SpecFlow Generator Version:1.9.0.0 // Runtime Version:4.0.30319.0 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. // </auto-generated> // ------------------------------------------------------------------------------ #region Designer generated code #pragma warning disable namespace ConsoleApplication4 { using TechTalk.SpecFlow; [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "1.9.0.77")] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [NUnit.Framework.TestFixtureAttribute()] [NUnit.Framework.DescriptionAttribute("NewPost")] public partial class NewPostFeature { private static TechTalk.SpecFlow.ITestRunner testRunner; #line 1 "NewPost.feature" #line hidden [NUnit.Framework.TestFixtureSetUpAttribute()] public virtual void FeatureSetup() { testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "NewPost", " Jakis opis tutaj...", ProgrammingLanguage.CSharp, ((string[])(null))); testRunner.OnFeatureStart(featureInfo); } [NUnit.Framework.TestFixtureTearDownAttribute()] public virtual void FeatureTearDown() { testRunner.OnFeatureEnd(); testRunner = null; } [NUnit.Framework.SetUpAttribute()] public virtual void TestInitialize() { } [NUnit.Framework.TearDownAttribute()] public virtual void ScenarioTearDown() { testRunner.OnScenarioEnd(); } public virtual void ScenarioSetup(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) { testRunner.OnScenarioStart(scenarioInfo); } public virtual void ScenarioCleanup() { testRunner.CollectScenarioErrors(); } [NUnit.Framework.TestAttribute()] [NUnit.Framework.DescriptionAttribute("Create a new post")] public virtual void CreateANewPost() { TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Create a new post", ((string[])(null))); #line 3 this.ScenarioSetup(scenarioInfo); #line 4 testRunner.Given("I have logged into CMS", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); #line 5 testRunner.When("I press the create a post button", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); #line 6 testRunner.Then("article should be created.", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); #line hidden this.ScenarioCleanup(); } } } #pragma warning restore #endregion

Z ciekawszych rzeczy, widzimy wyraźnie jak odpalany jest test:

[NUnit.Framework.TestAttribute()] [NUnit.Framework.DescriptionAttribute("Create a new post")] public virtual void CreateANewPost() { TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Create a new post", ((string[])(null))); #line 3 this.ScenarioSetup(scenarioInfo); #line 4 testRunner.Given("I have logged into CMS", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); #line 5 testRunner.When("I press the create a post button", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); #line 6 testRunner.Then("article should be created.", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); #line hidden this.ScenarioCleanup(); } }

CreateANewPost to zwykły test jednostkowy. Zaletą jest, że nie musimy nic zmieniać na serwerach CI. We wszelkich raportach jednak, będziemy mieli nazwę CreateANewPost, co nie jest zbyt eleganckie. Na przykład, w Test Explorer mamy:

image

Instalacja SpecRun odbywa się za pomocą NuGet:

image

 

W App.Config zobaczymy, że nUnit został zastąpiony SpecRun:

<specFlow> <!-- For additional details on SpecFlow configuration options see http://go.specflow.org/doc-config --> <!-- For additional details on SpecFlow configuration options see http://go.specflow.org/doc-config --><!-- use unit test provider SpecRun+NUnit or SpecRun+MsTest for being able to execute the tests with SpecRun and another provider --><unitTestProvider name="SpecRun" /><plugins> <add name="SpecRun" /> </plugins> </specFlow>

CodeBehind również zostanie ponownie wygenerowany:

// ------------------------------------------------------------------------------ // <auto-generated> // This code was generated by SpecFlow (http://www.specflow.org/). // SpecFlow Version:1.9.0.77 // SpecFlow Generator Version:1.9.0.0 // Runtime Version:4.0.30319.0 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. // </auto-generated> // ------------------------------------------------------------------------------ #region Designer generated code #pragma warning disable namespace ConsoleApplication4 { using TechTalk.SpecFlow; [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "1.9.0.77")] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [TechTalk.SpecRun.FeatureAttribute("NewPost", Description=" Jakis opis tutaj...", SourceFile="NewPost.feature", SourceLine=0)] public partial class NewPostFeature { private static TechTalk.SpecFlow.ITestRunner testRunner; #line 1 "NewPost.feature" #line hidden [TechTalk.SpecRun.FeatureInitialize()] public virtual void FeatureSetup() { testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "NewPost", " Jakis opis tutaj...", ProgrammingLanguage.CSharp, ((string[])(null))); testRunner.OnFeatureStart(featureInfo); } [TechTalk.SpecRun.FeatureCleanup()] public virtual void FeatureTearDown() { testRunner.OnFeatureEnd(); testRunner = null; } public virtual void TestInitialize() { } [TechTalk.SpecRun.ScenarioCleanup()] public virtual void ScenarioTearDown() { testRunner.OnScenarioEnd(); } public virtual void ScenarioSetup(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) { testRunner.OnScenarioStart(scenarioInfo); } public virtual void ScenarioCleanup() { testRunner.CollectScenarioErrors(); } [TechTalk.SpecRun.ScenarioAttribute("Create a new post", SourceLine=2)] public virtual void CreateANewPost() { TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Create a new post", ((string[])(null))); #line 3 this.ScenarioSetup(scenarioInfo); #line 4 testRunner.Given("I have logged into CMS", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); #line 5 testRunner.When("I press the create a post button", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); #line 6 testRunner.Then("article should be created.", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); #line hidden this.ScenarioCleanup(); } [TechTalk.SpecRun.TestRunCleanup()] public virtual void TestRunCleanup() { TechTalk.SpecFlow.TestRunnerManager.GetTestRunner().OnTestRunEnd(); } } } #pragma warning restore #endregion

Nadal będziemy mieli metodę o nazwie CreateANewPost, ale atrybuty nUnit zostały zastąpione SpecRun:

[TechTalk.SpecRun.ScenarioAttribute("Create a new post", SourceLine=2)] public virtual void CreateANewPost() { TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Create a new post", ((string[])(null))); #line 3 this.ScenarioSetup(scenarioInfo); #line 4 testRunner.Given("I have logged into CMS", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); #line 5 testRunner.When("I press the create a post button", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); #line 6 testRunner.Then("article should be created.", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); #line hidden this.ScenarioCleanup(); } [TechTalk.SpecRun.TestRunCleanup()] public virtual void TestRunCleanup() { TechTalk.SpecFlow.TestRunnerManager.GetTestRunner().OnTestRunEnd(); } }

Warto upewnić się, że w opcjach (Tools->Options), został faktycznie ustawiony SpecRun:

image

W Test Explorer mamy teraz dużo czytelniejszą nazwę testu:

image

Co więcej, po odpaleniu testów, zostanie również wygenerowany raport:

image

Klikając na nazwę testu, dostaniemy ładne podsumowanie w formacie GWT+kod:

image

Przeglądając wygenerowane pliki w Solution Explorer, zobaczymy również runtests.cmd. Oczywiście to skrypt odpalający testy:

image

Jeśli ktoś posiada kilkadziesiąt testów, napisanych w SpecFlow, warto pomyśleć o SpecRun. Oprócz raportu i przejrzystości, zyskamy również możliwość równoległego wykonywania testów, co w przypadku testów systemowych, UI może mieć znaczenie. Niestety za darmo mamy do dyspozycji wyłącznie evaluation mode. Nie jest on limitowany czasowo, ale testy są specjalnie opóźniane co można zaobserwować w Test Explorer.,