tSQLt – testowanie procedur, sortowanie

W poprzednim wpisie pokazałem jak testować wiersze zwracane przez procedury.  Niestety nie brały one pod uwagę kolejności wierszy, co może wydawać się nienaturalne. Załóżmy, że mamy następującą procedurę, zwracającą artykuły posortowane po tytule:

CREATE PROCEDURE [dbo].[GetArticles]
AS
BEGIN

	SELECT * FROM dbo.Articles  ORDER BY title asc;
END

Następnie analogicznie do poprzedniego wpisu, piszemy test jednostkowy:

  --Assemble
  EXEC tSQLt.FakeTable 'dbo.Articles'

  INSERT INTO dbo.Articles (Title) VALUES('C'),('B'),('A')

  CREATE TABLE ActualValues
  (
  		Id INTEGER,
		Title nvarchar(10),
		Content nchar(500)
  )

    CREATE TABLE ExpectedValues
  (
		Title nvarchar(10)
  )
  
 INSERT INTO dbo.ExpectedValues ( Title) VALUES  ( 'A'),('B'),('C')

 INSERT INTO ActualValues EXEC dbo.GetArticles;
  
  --Assert
  EXEC tSQLt.AssertEqualsTable @Expected = N'ExpectedValues',  @Actual = N'ActualValues'

Wstrzykujemy do naszego “mocka” wiersze w kolejności “C,B,A” i sprawdzamy, że na wyjściu pojawią się one w kolejności rosnącej ponieważ w procedurze mamy order by Title Asc. Odpalamy test i wszystko wydaje się w porządku. Niestety jest to błędny wniosek. Spróbujmy zmienić test do następującej postaci:

  --Assemble
  EXEC tSQLt.FakeTable 'dbo.Articles'

  INSERT INTO dbo.Articles (Title) VALUES('C'),('B'),('A')

  CREATE TABLE ActualValues
  (
  		Id INTEGER,
		Title nvarchar(10),
		Content nchar(500)
  )

    CREATE TABLE ExpectedValues
  (
		Title nvarchar(10)
  )
  
 INSERT INTO dbo.ExpectedValues ( Title) VALUES  ('C'),('B'),('A')

 INSERT INTO ActualValues EXEC dbo.GetArticles;
  
  --Assert
  EXEC tSQLt.AssertEqualsTable @Expected = N'ExpectedValues',  @Actual = N'ActualValues'

Zmieniliśmy kolejność oczekiwanych artykułów na błędną. Po uruchomieniu testu, przekonamy się, że wciąż zakończy się on sukcesem, czego oczywiście nie spodziewaliśmy się!

Niestety kolejność nie jest sprawdzana, co jak się dłużej zastanowimy ma sens, ale na pierwszy rzut oka jest mylące.

Musimy stworzyć kolumnę, która będzie identyfikowała kolejność. Naturalnym wyborem jest zatem “identity”:

  --Assemble
  EXEC tSQLt.FakeTable 'dbo.Articles'

  INSERT INTO dbo.Articles (Title) VALUES('C'),('B'),('A')

  CREATE TABLE ActualValues
  (
  		Id INTEGER,
		Title nvarchar(10),
		Content nchar(500),
		OrderSequence INT IDENTITY(1,1)
  )

    CREATE TABLE ExpectedValues
  (
		OrderSequence INT IDENTITY(1,1),
		Title nvarchar(10)
  )
  
 INSERT INTO dbo.ExpectedValues ( Title) VALUES  ('C'),('B'),('A')

 INSERT INTO ActualValues EXEC dbo.GetArticles;
  
  --Assert
  EXEC tSQLt.AssertEqualsTable @Expected = N'ExpectedValues',  @Actual = N'ActualValues'

Proszę zauważyć, że dodaliśmy OrderSequence do ExpectedValues oraz ActualValues. Po odpaleniu testu, dostaniemy błąd, ponieważ kolejność nie zgadza się:
1

Jeśli zmienimy ExpectedValues z powrotem do sekwencji rosnącej (A,B,C), wtedy test oczywiście zakończy się sukcesem.

Jak zatem to działa? Identity jest zwiększane automatycznie w momencie dodawania danych. Zatem ExpectedValues będzie miał wartości (1,A),(2,B) oraz (3,B). Analogicznie sprawa wygląda z ActualValues. Wynik GetArticles jest dodawany do tabeli ActualValues, która z kolei zawiera pole identity – za każdym razem OrderSequence jest zwiększane. Innymi słowy, dane musza być dodawane w tej samej kolejności, zarówno do ActualValues jak i ExpectedValues.

tSQLt – testowanie procedur

Dzisiaj kolejny scenariusz, w którym przetestujemy procedurę zwracającą dane. Tak jak w poprzednim wpisie, mamy następującą tabelę

CREATE TABLE [dbo].[Articles](
	[Id] [INT] NOT NULL,
	[Title] [NCHAR](10) NULL,
	[Content] [NCHAR](500) NULL,
PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

Załóżmy również, że mamy procedurę GetArticlesByTitle:

CREATE PROCEDURE GetArticlesByTitle
	@title NVARCHAR(10)
AS
BEGIN

	SELECT * FROM dbo.Articles WHERE Title=@Title;
END
GO

Metoda bardzo prosta, ale większość testów będzie miała taki sam szablon Zaczynamy od izolacji tabel, w tym przypadku “Articles”:

  EXEC tSQLt.FakeTable 'dbo.Articles'

Następnie musimy jakieś dane wstrzyknąć:

  INSERT INTO dbo.Articles (Title,Content) VALUES('art1','content1'),('est','content2')

W powyższym przykładzie, dane są dodawane do mock’a, a nie realnej tabeli ponieważ na początku mamy tSQLt.FakeTable.

Kolejnym etapem jest stworzenie dwóch pomocniczych tabel – ExpectedValues oraz ActualValues:

  CREATE TABLE ActualValues
  (
  		Id INTEGER,
		Title nvarchar(10),
		Content nchar(500)
  )

    CREATE TABLE ExpectedValues
  (
		Title nvarchar(10),
		Content nchar(500)
  )
  
  INSERT INTO dbo.ExpectedValues ( Title,Content) VALUES  ( 'art1','content1')

Całość testu wygląda tak:

ALTER PROCEDURE [sampleTests].[test when there is an article with the requested name it should be returned]
AS
BEGIN
  --Assemble
  EXEC tSQLt.FakeTable 'dbo.Articles'

  INSERT INTO dbo.Articles (Title,Content) VALUES('art1','content1'),('est','content2')

  CREATE TABLE ActualValues
  (
  		Id INTEGER,
		Title nvarchar(10),
		Content nchar(500)
  )

    CREATE TABLE ExpectedValues
  (
		Title nvarchar(10),
		Content nchar(500)
  )
  
  INSERT INTO dbo.ExpectedValues ( Title,Content) VALUES  ( 'art1','content1')
  --Act
 INSERT INTO ActualValues EXEC dbo.GetArticlesByTitle 'art1'
  
  --Assert
  EXEC tSQLt.AssertEqualsTable @Expected = N'ExpectedValues',  @Actual = N'ActualValues'
  
END;

Za pomocą AssertEqualsTable porównujemy, że ActualValues (wartości zwrócone z procedury) są równe naszym oczekiwaniom (ExpectedValues). Po wykonaniu testu, jeśli któraś z wartości nie jest taka sama, dostaniemy tabelkę porównującą wyniki:
1

Jak widzimy, testowanie jest dość proste. Najpierw dodajemy potrzebne dane,  a potem definiujemy nasze oczekiwania.

SQL prompt – IntelliSense w SQL Management Studio

Większość czasu spędzam z C#, a nie z TSQL. Tym bardziej narzędzie SQL prompt zaskoczyło mnie pozytywnie, ponieważ dzięki niemu, mam namiastkę IntelliSense  dostępnego w Visual Studio.

Bardzo często nie znam parametrów wejściowych danej funkcji w TSQL. W Visual Studio przyzwyczajony byłem, że naciskałem CTRL+Space i wszystko stawało się jasne.

Niestety SQL Prompt to płatne narzędzie i możemy tylko za darmo korzystać z wersji trial.  Moim zdaniem ceny są dość wysokie. ale jak ktoś pracuje w firmie, która zakupiła bundle różnych narzędzi od RedGate, wtedy może cieszyć się również SQL Prompt.

Załóżmy, że mamy tabelę “Article” i chcemy napisać Insert into. Za pomocą SQL Prompt, wystarczy, że napsizemy “INSERT INTO Article”, a zostanie wygenerowany szablon.

Screen:

1

Wygenerowany szablon:

INSERT INTO dbo.Articles
        ( Id, Title, Content )
VALUES  ( 0, -- Id - int
          N'', -- Title - nchar(10)
          N''  -- Content - nchar(500)
          )

Komentarze określające typ kolumn, to również bardzo przydatna informacja, jak i dobra praktyka.
Podobnie wygląda sprawa z wywoływaniem procedur. Wystarczy podać nazwę procedury, a automatycznie zostanie wygenerowany szablon (tutaj przykład dla tSQLt.AssertEqual):

EXEC tSQLt.AssertEquals @Expected = NULL, -- sql_variant
    @Actual = NULL, -- sql_variant
    @Message = N'' -- nvarchar(max)

Inna “ukryta” funkcjonalność SQL Prompt to skrót CTRL+F12. Wystarczy, że zaznaczymy dowolną procedurę lub kolumnę, a po naciśnięciu wspomnianego skrótu klawiszowego, dany element zostanie podświetlony w Object Explorer. Zachowanie analogiczne do Shift+Alt+L w Resharper i zaznaczania aktualnego pliku w Solution Explorer.

Ponadto, korzystam często z możliwości formatowania kodu dostarczonego przez SQL Prompt i podoba mi się, że słowa kluczowe automatycznie są zamieniane na wielkie litery.

tSQLt – izolacja tabel

Jedną z największych korzyści z tSQLt jest moim zdaniem izolacja danych. Załóżmy, że mamy na następującą tabelę:

USE [testdb]
GO

/****** Object:  Table [dbo].[Articles]    Script Date: 2016-02-20 2:24:25 PM ******/
SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Articles](
	[Id] [int] NOT NULL,
	[Title] [nchar](10) NULL,
	[Content] [nchar](500) NULL,
PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

GO

Następnie w naszym teście chcemy przetestować jakąś metodę operującą właśnie na tabeli Article. Z tego względu, musimy umieścić w niej jakieś dane. Przykład testu:

USE [testdb];
GO
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
--  Comments here are associated with the test.
--  For test case examples, see: http://tsqlt.org/user-guide/tsqlt-tutorial/
ALTER PROCEDURE [samples].[test tableIsolation]
AS
    BEGIN

        INSERT  INTO dbo.Articles
                ( Id ,
                  Title ,
                  Content
                )
        VALUES  ( 1 , -- Id - int
                  N'test title' , -- Title - nchar(10)
                  N' test content'  -- Content - nchar(500)
                );
    END;

Widzimy, że nie tworzymy mock’a. Każde odpalenie testu spowoduje dodanie danych do tabeli. Na szczęście wszystkie testy odpalane są transakcjach, zatem po wykonaniu testu, nastąpi rollback.

Może wydawać się to w porządku, ale operowanie na prawdziwej tabeli jest złe, ponieważ co prawda wszelkie zmiany dokonane przez test zostaną anulowane, to test będzie widział dane, które już istniały w tabeli przed odpaleniem testu. Z tego względu stworzenie “fake” tabeli jest lepszym rozwiązaniem:

ALTER PROCEDURE [samples].[test tableIsolation]
AS
    BEGIN

	EXEC tSQLt.FakeTable 'dbo.Articles'

        INSERT  INTO dbo.Articles
                ( Id ,
                  Title ,
                  Content
                )
        VALUES  ( 1 , -- Id - int
                  N'test title' , -- Title - nchar(10)
                  N' test content'  -- Content - nchar(500)
                );
     END;

Za pomocą FakeTable, tworzymy odizolowany mock tabeli. Efekt będzie taki, że:
– dane w tabeli Articles zapisane przed wykonaniem testu, nie są widocznie w trakcie wykonywania testu
– dane zapisane w tabeli Articles w trakcie testu, nie są widocznie na zewnątrz po wykonaniu testu.

Bez fake, wyłącznie drugi punkt byłby prawdziwy, ponieważ rollback by anulował wszystkie nowo dodane wiersze. W testach jednostkowych chcemy mieć zawsze pełną izolacje tak, że dane dodane do tabel poza testem, nie mają wpływu na wynik wykonania testów.
Jak zatem działa FakeTable? To zwykła procedura T-SQL, zatem możemy ją sprawdzić w kodzie:

ALTER PROCEDURE [tSQLt].[FakeTable]
    @TableName NVARCHAR(MAX),
    @SchemaName NVARCHAR(MAX) = NULL, --parameter preserved for backward compatibility. Do not use. Will be removed soon.
    @Identity BIT = NULL,
    @ComputedColumns BIT = NULL,
    @Defaults BIT = NULL
AS
BEGIN
   DECLARE @OrigSchemaName NVARCHAR(MAX);
   DECLARE @OrigTableName NVARCHAR(MAX);
   DECLARE @NewNameOfOriginalTable NVARCHAR(4000);
   DECLARE @OrigTableFullName NVARCHAR(MAX); SET @OrigTableFullName = NULL;
   
   SELECT @OrigSchemaName = @SchemaName,
          @OrigTableName = @TableName
   
   IF(@OrigTableName NOT IN (PARSENAME(@OrigTableName,1),QUOTENAME(PARSENAME(@OrigTableName,1)))
      AND @OrigSchemaName IS NOT NULL)
   BEGIN
     RAISERROR('When @TableName is a multi-part identifier, @SchemaName must be NULL!',16,10);
   END

   SELECT @SchemaName = CleanSchemaName,
          @TableName = CleanTableName
     FROM tSQLt.Private_ResolveFakeTableNamesForBackwardCompatibility(@TableName, @SchemaName);
   
   EXEC tSQLt.Private_ValidateFakeTableParameters @SchemaName,@OrigTableName,@OrigSchemaName;

   EXEC tSQLt.Private_RenameObjectToUniqueName @SchemaName, @TableName, @NewNameOfOriginalTable OUTPUT;

   SELECT @OrigTableFullName = S.base_object_name
     FROM sys.synonyms AS S 
    WHERE S.object_id = OBJECT_ID(@SchemaName + '.' + @NewNameOfOriginalTable);

   IF(@OrigTableFullName IS NOT NULL)
   BEGIN
     IF(COALESCE(OBJECT_ID(@OrigTableFullName,'U'),OBJECT_ID(@OrigTableFullName,'V')) IS NULL)
     BEGIN
       RAISERROR('Cannot fake synonym %s.%s as it is pointing to %s, which is not a table or view!',16,10,@SchemaName,@TableName,@OrigTableFullName);
     END;
   END;
   ELSE
   BEGIN
     SET @OrigTableFullName = @SchemaName + '.' + @NewNameOfOriginalTable;
   END;

   EXEC tSQLt.Private_CreateFakeOfTable @SchemaName, @TableName, @OrigTableFullName, @Identity, @ComputedColumns, @Defaults;

   EXEC tSQLt.Private_MarkFakeTable @SchemaName, @TableName, @NewNameOfOriginalTable;
END

Możemy zobaczyć, że oryginalna tabela ma zmienianą nazwę, a potem tworzymy fake o takiej samej nazwie jak oryginalna tabela.

Na zakończenie bardzo ważna uwaga odnośnie FakeTable. Wszelkie “constraints” nie są wymuszane. W powyższym przypadku, klucz główny jest wymaganym polem i następujący kod normalnie spowoduje wyjątek.

INSERT  INTO dbo.Articles (Title) VALUES('test')

Wyjątek:
“Cannot insert the value NULL into column ‘Id’, table ‘testdb.dbo.Articles’; column does not allow nulls. INSERT fails.”

Z kolei w teście powyższy kod nie spowoduje żadnych błędów. Jak wiemy, FakeTable to nowa tabela ponieważ stara ma zmienianą nazwę. Ta nowa tabela, nie ma dodanych żadnych “constraints”. W praktyce jest to bardzo korzystne. Testy jednostkowe powinny skupiać się wyłącznie na jednej funkcjonalności. Z tego względu, jeśli tabela ma 20 wymaganych kolumn, a my testujemy wyłącznie jedną, nie musimy pozostałych 19 wypełniać danymi.

tSQLt- narzędzie SQL Test

W ostatnim wpisie, przedstawiłem prosty test wykonywany po stronie bazy danych. Zanim przejdę do omawiania bardziej zaawansowanych tematów, warto przyjrzeć się narzędziu SQL Test. Jest to proste narzędzie od RedGate, umożliwiające odpalenie i tworzenie testów. Zamiast np. ręcznie wywoływać “NewTestClass” czy “RunAll”, odpalamy testy tak samo jak w Visual Studio.

Po zainstalowaniu wersji trial, zobaczymy w SQL Management Studio następujące okno:

1

Jak widać, możemy uruchomić dowolny test. Jeśli chcemy dodać nowy test, pojawi się okno gdzie możemy podać nazwę testu oraz test fixture:

2

Narzędzie stworzy schema oraz szablon procedury reprezentującej test:

USE [testdb]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
--  Comments here are associated with the test.
--  For test case examples, see: http://tsqlt.org/user-guide/tsqlt-tutorial/
ALTER PROCEDURE [divideTests].[test anotherTest]
AS
BEGIN
  --Assemble
  --  This section is for code that sets up the environment. It often
  --  contains calls to methods such as tSQLt.FakeTable and tSQLt.SpyProcedure
  --  along with INSERTs of relevant data.
  --  For more information, see http://tsqlt.org/user-guide/isolating-dependencies/
  
  --Act
  --  Execute the code under test like a stored procedure, function or view
  --  and capture the results in variables or tables.
  
  --Assert
  --  Compare the expected and actual values, or call tSQLt.Fail in an IF statement.  
  --  Available Asserts: tSQLt.AssertEquals, tSQLt.AssertEqualsString, tSQLt.AssertEqualsTable
  --  For a complete list, see: http://tsqlt.org/user-guide/assertions/
  EXEC tSQLt.Fail 'TODO:Implement this test.'
  
END;

Narzędzie jest bardzo proste i dość znacząco ułatwia posługiwanie się tSQLt. Moim zdaniem jednak cena zdecydowanie nie jest adekwatna to możliwości produktu.

tSQLt – testy jednostkowe bazy danych w SQL Server

Często logika zawarta w procedurach jest dość skomplikowana. W zależności od projektu, może okazać się,  że potrzebujemy testów jednostkowych. Dzięki tSQLt możemy  testować tSQL w analogiczny sposób do nUnit+moq, czyli:

  • Dane po wykonaniu testu są usuwane. Każdy test jest wykonywany w transakcji. Nie musimy się zatem martwić, że testując coś będziemy zaśmiecać bazę danych.
  • Każdy element może być odizolowany, czyli możemy stworzyć mock dla tabeli, procedury lub funkcji.
  • Testy wykonywane są w pełnej izolacji.
  • Łatwa integracja z CI board.

tSQLt to zespól procedur, które umożliwiają wykonywanie testów bezpośrednio w bazie danych. Musimy zatem najpierw zainstalować je tak jak w przypadku c# był to NuGet. Pakiet możemy znaleźć tutaj. W zestawie znajdują się następujące skrypty:

  • SetClrEnabled.sql – musimy najpierw włączyć CLR na bazie.
  • tSQLt.class.sql – zestaw procedur potrzebny do wykonywania testów (m.in. asercje).
  • Example.sql – przykłady testów. Oczywiście nie musimy tego instalować, ale stanowi to znakomitą dokumentację.

W celu napisania naszego pierwszego testu, uaktywniamy CLR i odpalamy następnie tSQL.class.sql. Po instalacji, zobaczymy, że wiele nowych procedur zostało dodanych:

1

Stwórzmy teraz prostą funkcję w TSQL:


CREATE FUNCTION DivideNumbers
(
@a integer,@b integer
)
RETURNS integer
AS
BEGIN
declare @result integer;
set @result = @a/@b;

RETURN @result;

END

Oczywiście przykład typowo teoretyczny, ale nie ma teraz to znaczenia.

Tak jak w nUnit zaczynamy od TestFixture:


EXEC tSQLt.NewTestClass 'divideTests';

Z punktu widzenia SQL Server, divideTests stanowi schema:

2

W tSQLt “TestClass” to po prostu testFixture. To w nim będziemy przechowywać nasze testy. Stwórzmy zatem pierwszy test:

create PROCEDURE divideTests.[testDivideNumbers]	
AS
BEGIN

	declare @actualResult int
	declare @expectedValue int

	set @expectedValue=12

	select @actualResult=dbo.DivideNumbers(144,12)

	exec tSQLt.AssertEquals  @expected=@expectedValue, @actual=@actualResult
END

Jak widzimy, jest to zwykła procedura, która została stworzona w odpowiednim schema, który jest skojarzony z TestClass (divideTests). Każdy test to standardowe etapy Arrange, Act oraz Assert. Ten ostatni wykonujemy za pomocą szeregu procedur dostarczonych przez tSQLt – w powyższym przypadku jest to AssertEquals.

Jeśli chcemy uruchomić wszystkie testy naraz wtedy wykonujemy:

exec tSQLt.RunAll

W rezultacie zobaczymy raport:

3

Zmieńmy teraz naszą funkcję, tak aby zawierała błąd:

ALTER FUNCTION [dbo].[DivideNumbers]
(
	@a integer,@b integer
)
RETURNS integer
AS
BEGIN
	declare @result integer;
	set @result = @a/@b+1; -- blad

	RETURN @result;

END

Po odpaleniu testów, dostaniemy tym razem błąd asercji:

4

Jeśli chcemy uruchomić wyłącznie jeden zestaw testów, wtedy lepiej użyć:

EXEC tSQLt.Run ‘divideTests’;

Oczywiście tworzenie lub testowanie powyższych funkcji mija się z celem. W przyszłych postach pokaże jak testować procedury, które bardzo często zawierają skomplikowaną logikę (co nierzadko jest złą praktyką, ale to inny już temat).

Prawdziwe korzyści z tSQLt zauważa się, gdy należy odizolować pewne tabele i operacje. Powyższy przykład to funkcja, która nie ma żadnych zależności więc tSQLt oprócz asercji nie wniósł nic nowego. Wpis miał na celu pokazać wyłącznie instalacje tSQLt, stworzenie test fixture (“TestClass”) oraz uruchamianie testów.

Trace vs Debug

W .NET istnieją dwie przydatne klasy do logowania wszelkich informacji: Trace oraz Debug. Często różnica nie jest jasna oraz klasy są ze sobą mylone. Co się dokładnie dzieje, gdy napiszemy następujący fragment kodu?

   Trace.WriteLine("Trace test");
   Debug.WriteLine("Debug test");

Efekt wydaje się podobny, w okienku debug zobaczymy wykonane logi:

1

Najlepiej zajrzeć do źródeł powyższych klas. Debug.WriteLine wygląda następująco:

   [System.Diagnostics.Conditional("DEBUG")]        
   public static void WriteLine(string message) 
   {
        TraceInternal.WriteLine(message);
   }

Z kolei Trace.WriteLine:

        [System.Diagnostics.Conditional("TRACE")]
        public static void WriteLine(string message) {
            TraceInternal.WriteLine(message);
        }

Pozostałe metody Trace oraz Debug wyglądają analogicznie. Wszystkie z nich (zarówno Debug jak i Trace) korzystają z TraceInternal. Jedyna różnica to atrybut Conditional. Nie trudno domyślić się, że Debug będzie widoczny tylko w trybie Debug. Jeśli przejdziemy do Release, Debug.WriteLine nie zostanie skompilowany czyli nie zobaczymy nic na wyjściu. Z kolei symbol “TRACE” jest domyślnie zawsze włączany w proces kompilacji, co możemy sprawdzić we właściwościach projektu:

2

Warto również zauważyć, że za pomocą obydwu klas można zapisywać logi w dowolnych miejscach, również w plikach czy bazach danych. Wystarczy odpowiednio skonfigurować Listeners:

ConsoleTraceListener myWriter = new ConsoleTraceListener();
Trace.Listeners.Add(myWriter);

AKKA.NET – zdalni aktorzy

Jak wspomniałem w jednym z wcześniejszych już wpisów, nie ma znaczenia, gdzie aktor jest zlokalizowany. Dzięki AKKA.NET jest to szczegół  konfiguracyjny. Jeśli pewnego dnia, stwierdzimy, że wykonywanie obliczeń na jednym komputerze nie wystarcza, wtedy po prostu  zmieniamy konfigurację, aby hostować danego aktora gdzieś indziej. Framework zadba o komunikację (TCP) między węzłami znajdującymi się w innych sieciach. W ten sposób, bardzo łatwo jest skalować logikę w następujący sposób: wątek->proces->komputer->sieć komputerów.

W dokumentacji znajdziemy szczegóły, ale moim zdaniem brakuje tam prostego przykładu polegającego na wysłaniu wiadomości z węzła A do B.

Zacznijmy od stworzenia struktury projektu. Warto stworzyć jedną bibliotekę, która będzie zawierać wyłącznie kod aktorów. W naszym przypadku będzie to EchoServerActor:

    public class EchoServerActor : ReceiveActor
    {
        public EchoServerActor()
        {
            Receive<EchoMsg>(msg =>
            {
                Sender.Tell($"Server:{AppDomain.CurrentDomain.FriendlyName},{msg.Text}");
            });
        }
    }

    public class EchoMsg
    {
        public string Text { get; set; }
    }

Jak widać, kod nie różni się niczym od implementacji “lokalnych” aktorów. Po odebraniu wiadomości, wyświetlamy na ekranie nazwę domeny oraz przesłaną wiadomość. Wyświetlenie aktualnej domeny będzie pomocne, w analizie gdzie kod został tak naprawdę wykonany. EchoServeActor będzie służył nam jako “serwer”. W praktyce, komunikacja odbywa się klient-klient i nie należy projektować systemów w sposób scentralizowany. Awaria jednego aktora nie powinna spowodować paraliżu całego systemu. W poście jednak, chcemy napisać jak najprostszy fragment kodu, stąd te uproszczenie.

Jako “klient” posłuży nam następująca klasa:

    public class EchoReceiverActor : ReceiveActor
    {
        public EchoReceiverActor()
        {
            Receive<string>(msg =>
            {
                Console.WriteLine($"Received on {AppDomain.CurrentDomain.FriendlyName}:{msg}");
            });

            Receive<EchoMsg>(msg =>
            {
                var remoteActor = Context.ActorSelection("akka.tcp://Server@localhost:8081/user/EchoServerActor");
                remoteActor.Tell(msg);
            });
        }
    }

Po odebraniu wiadomości “EchoMsg” uzyskujemy referencję za pomocą ścieżki (więcej szczegółów tutaj). W praktyce, nie chcemy umieszczać w kodzie aktora, informacji o jego lokalizacji – powinno to mieć miejsce np. w pliku konfiguracyjnym. Powyższy kod jest zatem złapaniem bardzo ważnej zasady o neutralności fizycznej lokalizacji aktora. W ramach wpisu chcę napisać kod jednak jak najprościej tylko można. W każdym razie, po odebraniu EchoMsg, EchoReceiverActor wyśle wiadomość do zdalnego aktora.

Z kolei jeśli przyjdzie wiadomość w formie czystego tekstu (string), wtedy wyświetlamy ją. Nie trudno domyślić się, że w naszym przykładzie taka wiadomość będzie pochodzić od zdalnego aktora.

Innymi słowy, najpierw po stronie klienta wysyłamy EchoMsg do EchoReceiverActor. Aktor z kolei prześle tą wiadomość zdalnie do EchoServerActor, który z kolei odpowie tekstem do EchoReceiverActor.

Przejdźmy teraz do konfiguracji “serwera”:

         var config = ConfigurationFactory.ParseString(@"
akka {  
    actor {
        provider = ""Akka.Remote.RemoteActorRefProvider, Akka.Remote""
    }
    remote {
        helios.tcp {
            transport-class = ""Akka.Remote.Transport.Helios.HeliosTcpTransport, Akka.Remote""
            applied-adapters = []
            transport-protocol = tcp
            port = 8081
            hostname = localhost
        }
    }
}
");

        using (var system = ActorSystem.Create("Server", config))
        {
           system.ActorOf<EchoServerActor>("EchoServerActor");

           Console.ReadLine();
        }

Konfiguracja w prawdziwych projektach umieszczana jest w App\Web.Config, tak aby mnożna ją było zmienić bez potrzeby rekompilacji. Widzimy, że serwer będzie nasłuchiwać na porcie 8081. Zostanie również stworzony system o nazwie “Server” oraz pojedynczy aktor o nazwie “EchoServerActor”.

Klient z kolei wygląda następująco:

            Console.ReadLine();

            var config = ConfigurationFactory.ParseString(@"
akka {  
    actor {
        provider = ""Akka.Remote.RemoteActorRefProvider, Akka.Remote""
    }
    remote {
        helios.tcp {
            transport-class = ""Akka.Remote.Transport.Helios.HeliosTcpTransport, Akka.Remote""
		    applied-adapters = []
		    transport-protocol = tcp
		    port = 0
		    hostname = localhost
        }
    }
}
");

    using (var system = ActorSystem.Create("Client", config))
    {
         var receiver = system.ActorOf(Props.Create<EchoReceiverActor>());

         receiver.Tell(new EchoMsg { Text = "Hello world" });
         Console.ReadLine();
    }

W przypadku “klienta” nie musimy określać portu. Jak wiemy, wszystko działa na zasadzie klient-klient, ale w kodzie sami nie musimy nic samemu wysyłać do klienta – stąd nie ma to znaczenia. Wartość zero oznacza, że port zostanie wybrany automatycznie. Następnie wysyłamy wiadomośc EchoMsg do aktora EchoReceiverActor. Jak wiemy z powyższego kodu, EchoReceiverActor jest hostowany w systemie “Client”. Następnie wyśle on wiadomość do zdalnego systemu “Server”.

Po uruchomieniu serwera, zobaczymy, że faktycznie system Server nasłuchuje na 8081:

1

Z kolei po uruchomieniu klienta, zobaczymy, że nasłuchuje on na automatycznie wybranym porcie 8591:

2

Z powyższego screenu również widać, że wiadomość została z powrotem przesłana do klienta. Dzięki wyświetleniu nazwy domeny, widzimy kiedy wiadomość została odebrana przez serwer (echo) i przesłana do kienta.

DbUp – aktualizacja baz danych

DbUp jest prostą biblioteką, przeznaczoną do aktualizacji baz danych. Jeśli korzystamy z ORM, zwykle wtedy dany framework posiada już analogiczną funkcjonalność. Na przykład, EntityFramework wspiera migrację, która umożliwia automatyczną aktualizacje tabel i procedur.

Z drugiej strony, nie zawsze jest potrzeba korzystania z tak ciężkich rozwiązań. Bardzo popularną biblioteką do odczytu danych z baz jest Dapper.  Niestety nie posiada on mechanizmu podobnego do Entity Framework migrations.

W takich przypadkach, DbUp jest bardzo przydatny. Aktualnie wspiera kilka baz danych m.in. SQL Server, MySQL i FireBird. Idea jest bardzo prosta – wystarczy w projekcie stworzyć folder z listą skryptów do wykonania.  Załóżmy, że mamy 2 skrypty :

CREATE TABLE [dbo].Persons
(
   [Id] INT NOT NULL PRIMARY KEY, 
    [FirstName] NCHAR(10) NULL, 
    [LastName] NCHAR(10) NULL
)

CREATE TABLE [dbo].Articles
(
	[Id] INT NOT NULL PRIMARY KEY, 
    [Title] NCHAR(10) NULL, 
    [Content] NCHAR(500) NULL
)

Każdy ze skryptów powinien zostać zapisany w osobnym pliku, np.:

1

Następnie należy ustawić Build Action na Embedded Resource:

2
Oczywiście jeśli chcemy, aby DbUp wykonał powyższe skrypty, należy zainstalować odpowiedni pakiet za pomocą:
Install-Package DbUp

W celu uruchomienia aktualizacji wystarczy:

            var connectionString = @"Server=DESKTOP-IK0BKOF\SQLEXPRESS;database=testdb; Trusted_connection=true";

            var upgrader =
                DeployChanges.To
                    .SqlDatabase(connectionString)
                    .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
                    .LogToConsole()
                    .Build();

            var result = upgrader.PerformUpgrade();

            if (!result.Successful)
                Console.WriteLine(result.Error);

DbUp automatycznie połączy się z bazą i wykona skrypt po skrypcie:

3

Jeśli ponownie spróbujemy wykonać aktualizację, wtedy DbUp zorientuje się, że nie trzeba już żadnych skryptów wykonywać:

4

Załóżmy, że po pewnym czasie dodaliśmy kolejną tabelę:

CREATE TABLE [dbo].Contacts
(
	[Id] INT NOT NULL PRIMARY KEY, 
    [FirstName] NCHAR(10) NULL, 
    [LastName] NCHAR(10) NULL
)

5
Po uruchomieniu, DbUp doda wyłącznie nową tabelę (Contacts):

6

Pozostaje wyjaśnij, w jaki sposób DbUp wie, które skrypty należy wykonywać? Zaglądając do bazy danych, zobaczymy, że nowa tabela “SchemaVersions” została utworzona. Wykonując Select na niej, przekonamy się,  że DbUp przechowuje listę skryptów, które zostały wykonane:

7

Oprócz aplikacji konsolowej, można korzystać również ze skryptów PowerShell (szczegóły w dokumentacji). Innym, częstym podejściem jest wykonywanie aktualizacji przed uruchomieniem aplikacji.

Jak widać, DbUp to bardzo proste narzędzie, wykonujące po prostu listę skryptów. Przydaje się jednak w częstych releasach,  gdzie należy pamiętać historię zmian w DB.

Exceptionless – centralne przechowywanie logów

Standardowo w aplikacjach używa się takich frameworków jak log4net czy nlog w celu logowania kluczowych informacji jak i  wyjątków. Bardzo szybko staje się jasne (szczególnie w przypadku micro-serwisów), że analizowanie plików tekstowych z logami jest czasochłonne.

Z tego względu dobrze mieć centralne repozytorium logów i łatwy do niego dostęp. Większość rozwiązań umożliwia dzisiaj indeksowanie oraz łatwe przeszukiwanie danych. Jednym z bardziej znanych produktów jest splunk. Umożliwia agregację logów z różnych źródeł i szybkie wyszukiwanie za pomocą zaawansowanych zapytań.  Za pomocą splunk łatwe jest wygenerowanie raportów np. pokazujących jaki typ wyjątku był najczęściej wyrzucany w określonym czasie.  Niestety rozwiązanie jest dość drogie.

Exceptionless nie jest alternatywą dla splunk. Jeśli jednak potrzebujemy wyłącznie centralnego repozytorium logów z możliwością wykonania prostych zapytań, wtedy ExceptionLess jest tanią lub nawet darmową alternatywą. ExceptionLess jest open-source, można zatem pobrać kod źródłowy i bez problemu hostować to na wewnętrznym serwerze. Możliwe jest również hostowanie na ich serwerach i wtedy cena zależy od liczby projektów i innych parametrów.  Dla testów jednak (jeden projekt), można stworzyć darmowe konto.

Spróbujmy zatem napisać proste demo. Zaczynamy od stworzenia darmowego konta na https://be.exceptionless.io/signup. Następnie tworzymy nowy projekt:

1

Po wybraniu typu projektu (ASP.NET MVC), dostaniemy informacje, co należy wykonać dalej:

2

Jak widzimy, musimy zainstalować odpowiedni pakiet za pomocą Install-Package Exceptionless.Mvc. Ze screenu wynika również, że musimy ustawić APIKey reprezentujący projekt, który właśnie stworzyliśmy. Wystarczy ustawić odpowiedni element w Web.config

  <exceptionless apiKey="API_KEY_HERE" />

I to naprawdę wszystko! Przyglądając się Web.config zobaczymy również, że dodatkowy moduł został dołączony:

  <system.webServer>
    <validation validateIntegratedModeConfiguration="false" />
    <modules>
      <add name="ExceptionlessModule" type="Exceptionless.Mvc.ExceptionlessModule, Exceptionless.Mvc" />
    </modules>
  </system.webServer>

Dzięki temu, nieobsłużone wyjątki zostaną zapisane automatycznie w bazie ExceptionLess. Spróbujmy zatem wyrzucić jakiś wyjątek w kontrolerze:

        public ActionResult Index()
        {
            throw new ArgumentNullException("Any message");

            return View();
        }

Przechodząc teraz do zakładki “Exceptions”, zobaczymy, że wyjątek został przechwycony przez ExceptionLess i zapisany:

3

Klikając na wpisie, przejdziemy do szczegółów:

4

U góry aplikacji widzimy również opcje wyszukiwania, która wspiera m.in. znak “*”:

5

Dane są indeksowane, zatem wyszukiwanie jest szybkie.

W ustawieniach konta, możemy również skonfigurować powiadomienia o np. krytycznych wyjątkach lub otrzymywać codzienny raport. ExceptionLess nie służy wyłącznie do przechowywania wyjątków, ale do logowania jakichkolwiek zdarzeń. Do każdego zdarzenia można dołączać tag, dzięki temu exceptionless jest przydatny w monitorowaniu działania aplikacji. Za pomocą raportów, w łatwy sposób możemy dowiedzieć się, które elementy aplikacji są najczęściej wykorzystywane.