Z pamiętnika SaaSa: Co warto zastosować w projekcie SaaS

Z pamiętnika SaaSa

W poprzednim artykule pokazałem, w jaki sposób zaczynam nowy projekt – czym się kieruję przy dobieraniu architektury oraz technologi. Teraz czas odpalić Visual Studio i stworzyć nasz testowy projekt ASP.NET Core i Blazor. W tym artykule nie będę pokazywał poszczególnych kroków tworzenia tego projektu, a raczej skupię się na opisaniu najciekawszych elementów i decyzji, które podjąłem.

Domyślny projekt

W przypadku SoloProgramisty, chcemy uzyskać jak najwięcej, jak najmniejszym kosztem. Projekt naszej aplikacji oparłem na szablonie w Visual Studio Blazor WebAssembly App, z zaznaczoną opcją hostowania w aplikacji ASP.NET Core i autentykacją Individual.

Ustawienia projektu

Jest to dobry punkt początkowy, który daje nam następujące elementy:

  • Prostą aplikację Blazor, która korzysta z Web Api w ASP.NET Core – czyli dokładnie to czego potrzebujemy
  • Mechanizm logowania oparty na OAuth2, czyli zaawansowany i szeroko stosowany sposób na zabezpieczenie aplikacji. Zdecydowanie na plus
  • Mechanizm tworzenia użytkowników – w domyślnej wersji korzystający z Entity Framework. My tą część zmodyfikujemy aby użyć NHibernate, ale poza tym, dostajemy wiele elementów związanych z tworzeniem kont, które będziemy mogli wykorzystać.

Opcje kompilatora

Treat warning as errors

Od razu po utworzeniu nowego projektu polecam włączyć opcję Treat warning as errors (Traktuj ostrzeżenia jako błedy). Mimo, iż ostrzeżenia nie zawsze oznaczają problemy w kodzie (dlatego są tylko ostrzeżeniami, a nie błędami), jednak często pokazują, że jest coś nie tak. Dzięki takiemu ustawieniu kompilatora, będziesz zmuszony zwracać uwagę na wszystkie ostrzeżenia i sie ich pozbywać. Jeśli zrobisz to na początku projektu, to nawet nie odczujesz różnicy. W przypadku, gdybyś zdecydował się włączyć tą opcję jak projekt już będzie rozwinięty (i będziesz miał np. 500 ostrzeżeń), to będzie to już większym wyzwaniem.

Ustawienia kompilatora - Traktuj ostrzeżenia jak błędy

Nullable Reference Types

Czyli dokładniejsze sprawdzanie referencyjnych typów nullowalnych (nie wiem jak lepiej to przetłumaczyć 😉 ). Dzięki włączeniu tej opcji, kompilator będzie sprawdzał, czy każdy obiekt, z którego korzystamy w kodzie, ma ustawioną wartość, czy może mieć wartość null itp. Więcej informacji znajdziesz tutaj.

Aby włączyć ten mechanizm, musimy zrobić to ręcznie w plikach projektów. Zatem zaznaczamy każdy projekt w okienku Solution Explorer i dodajemy linijkę <Nullable>enable</Nullable>

Nullable Reference Types

Tę opcję ustawiłem głównie w celach edukacyjnych. Najlepiej wyrobić sobie o czymś zdanie po prostu z tego korzystając. Jeśli chciałbyś wiedziec co sądzę o nullowalnych typach referencyjnych i czy warto z nich korzystać, daj znać w komentarzach.

Struktura projektu

Czas teraz przeanalizować strukturę projektu po moich zmianach.

Kod źródłowy znajdziesz tutaj:

GitHub: https://github.com/robocik/NotesApp_V1

Na początek wystawiam następujące operacje w WebAPI:

  • GET /notes – pobranie wszystkich notatek
  • GET /notes/id – pobranie notatki o konkretnym id
  • POST /notes – utworzenie notatki
  • PUT /notes – edycja notatki
  • DELETE /notes – usunięcie notatki

Kontrolery WebApi mają być tylko punktem wejścia i przekazywać kontrolę do naszych Command i Query (tak korzystam z CQRS)

        [HttpGet("{id}")]
        public async Task<IActionResult> GetNoteDetails(Guid id)
        {
            var query = new GetNoteDetailsQuery(id);
            var retValue = await Mediator.Send(query).ConfigureAwait(false);
            return Ok(retValue);
        }

        [HttpPost]
        public async Task<IActionResult> CreateNote([FromBody] CreateNoteParam? param)
        {
            if (param == null)
                return BadRequest();

            if (!ModelState.IsValid)
                return BadRequest(ModelState);

            var command = Mapper.Map<CreateNoteCommand>(param);

            await Mediator.Send(command).ConfigureAwait(false);
            return Created("note", null);
        }

Logika biznesowa znajduje się w nowym projekcie NotesApp.Logic (Class Library).

Nasze projekty w SolutionExplorer

Dodatkowych projektów możesz utworzyć więcej, żeby jeszcze bardziej podzielić swoją aplikację na moduły. Ważne, żebyś logikę biznesową trzymał niezależnie od ASP.NET Core. Pilnuj, żeby w nowym projekcie nie było żadnych zależności związanymi z konkretnymi technologiami (ASP.NET Core, WPF, Azure itp).

W tym momencie możesz zapytać, dlaczego to takie ważne.

Wydzielenie logiki biznesowej

Odzielenie logiki biznesowej od technologii, na której będzie uruchamiana, ma bardzo wiele plusów i niewiele minusów:

Plusy:

  • Łatwo przenieść logikę biznesową do innej aplikacji. Teraz piszesz aplikację ASP.NET Core, a może w przyszłości chciałbyś zmienić serwer na Windows Service? Albo wręcz napisać prostą apkę w WPF, która miałaby całą logikę biznesową? W przypadku, gdy kod wykorzystywałby klasy ASP.NET, to masz problem. Będziesz miał mnóstwo pracy, żeby to przenieść. Wydaje Ci się, że trochę pojechałem z tą zmianą ASP.NET Core na WPF? Może, ale także w przypadku, gdy pisałeś aplikację na .NET Framework 4 (i ASP.NET MVC), a teraz chciałbyś przejść na ASP.NET Core możesz mieć problemy. Jest to oczywiście do zrobienia, ale między tymi wersjami są różnice. I prawdopodobnie przeniesienie 1-1 wiązałoby się z większym nakładem pracy.
  • Testy automatyczne – o wiele prościej napisać test integracyjny samej logiki biznesowej, niż kontrolera WebApi, który ją odpala. Dodatkowo szybkość uruchamania testów integracyjnych jest większa od testów WebApi. Na temat testów napiszę oddzielny artykuł.

Minusy:

  • Nie masz dostępu do wszystkich klas i właściwości, dlatego musisz je przekazywać z ASP.NET do logiki biznesowej, co powoduje MINIMALNIE więcej pracy. Np. gdybyś chciał w logice biznesowej wysłać maila, w którego treści wstawiłbyś adres URL Twojej aplikacji, to niestety tego adresu nie uzyskasz – musisz go jakoś przekazać z ASP.NET.

Minusy w tym przypadku są pomijalne. Gdy nabierzesz wprawy, to tworzenie odpowiedniej abstrakcji, żeby wydzielić pewne elementy z logiki biznesowej, nie zajmie Ci dużo czasu. A plusy zdecydowanie ułatwią Ci życie w przyszłości.

Ważne jest abyś właściwe wzorce i podejścia zastosował w swoim projekcie jak najszybciej, gdyż wtedy nie wymagają praktycznie więcej pracy. Gdy w połowie projektu zdecydowałbyś się wprowadzić jakąś zmianę (wprowadzić nową abstrakcję, czy przenieść kod w inne miejsce), to wymagałoby to sporo wysiłku.

CQRS

CQRS (Command and Query Responsibility Segregation) jest wzorcem polegającym na oddzieleniu operacji zmieniających stan bazy (Command) od tych, które tylko odczytują (Query). Jeśli nie znasz tego podejścia to możesz poczytać o tym tutaj. Ja powiem Ci, dlaczego zdecydowałem się na zastosowanie tego wzorca w swoim programie easyRenti.

Jedną z głównch zalet CQRS jest POTENCJALNE zwiększenie wydajności i skalowalności aplikacji. Dlaczego potencjalne? Bo nie ma nic za darmo. Samo rozdzielenie odczytu i zapisu nic nie zmienia. Nadal możesz napisać mało wydajny kod. Aby uzyskać większą wydajność (skalowalność), musisz tworzyć aplikację w odpowiedni sposób. I CQRS daje Ci taką możliwość:

  • Zapytania dla zapisu i odczytu mogą korzystać z innego podejścia. Mógłbyś przy zapisie używać NHibernate, ale już odczyt oprzeć na bezpośrednich zapytaniach SQL
  • Możesz mieć kilka różnych baz danych odpowiednich dla konkretnego kontekstu
  • Kod odczytu zwykle jest prostszy i nie wymaga takiego nakładu, jak kod do zapisu, dlatego można mieć oddzielne klasy dla obu operacji.

Pamietaj, że CQRS tylko daje Ci możliwość wprowadzenia powyższych usprawnień. Gdy tego nie zrobisz, to wydajność Twojej aplikacji raczej nie wzrośnie.

W easyRenti (i naszej testowej aplikacji) nie implementowałem żadnych specjalnych rozwiązań zwiększających wydajność. Na początkowym etapie projektu, gdy nie ma jeszcze zbyt wielu użytkowników, wydajność i tak nie będzie problemem. CQRS wprowadziłem natomiast z dwóch powodów:

  • Przygotowanie aplikacji na przyszłość
  • W celach edukacyjnych

Czy w przypadku SoloProgramisty i optymalizacji czasu tworzenia projektu jest sens wprowadzania CQRS myśląc tylko o przyszłych potrzebach? To zależy 😉 Nie twierdzę, że powinieneś stosować ten wzorzec projektowy w każdej aplikacji. Spokojnie w easyRenti mógłbym zastosować bardziej tradycyjne podejście.

Z drugiej strony jednak uważam, że jak zastosujesz CQRS od początku pisania aplikacji, to nie wprowadza on znacząco więcej pracy. Z tego względu nie czuję, że ten wzorzec obecnie mnie spowalnia przy dodawaniu nowych ficzerów – po prostu muszę bardziej pilnować segregacji operacji i tyle.

CQRS w praktyce
CQRS w praktyce

Reasumując – relatywnie małym kosztem, mam aplikację przygotowaną na przyszłość 🙂 (No i oczywiście CQRS dodaje +10 do lansu w pewnych kręgach 🙂 )

Uwspólniaj kod

Jestem dużym zwolennikiem uwspólniania jak największych ilości kodu. Nie ma znaczenia czy mówimy o froncie, czy backendzie. W każdym projekcie wydzielam coś na kształt własnego frameworka, który zawiera wspólne elementy. Dzięki temu, rozwijanie aplikacji w przyszłości jest ułatwione, gdyż składamy program z bloków, które do siebie pasują i działają w całym projekcie tak samo.

Kiedy uwspólniać kod? Są dwie metody:

  1. Od razu. Tę metodę polecam osobom z doświadczeniem. Pisząc już któryś program z rzędu wiemy, co warto jest uwspólnić. Osobiście uważam, że lepiej uwspólnić za dużo niż za mało. Przez uwspólnienie mam na myśli wydzielenie kodu do oddzielnej klasy, czy metody. Nawet jeśli okaże się, że dany kod wykorzystaliśmy tylko w jednym miejscu, to przez umieszczenie go w osobnej klasie nie bedzie żadnych skutków ubocznych.
  2. Przy pierwszej potrzebie skopiowania (napisania) tego samego kodu. Prosta sprawa, piszemy kod w standardowy sposób. W momencie, gdy w innym miejscu projektu robimy (prawie) to samo, to uwspólniamy kod.

Tutaj małe wyjaśnienie. Jako SoloProgramista, staram się optymalizować swoją prace. Dotyczy to także wydzielania kodu i tworzenia „frameworka”. Tą czynność też należy uprościć. Nie chodzi w tym punkcie o to, żeby dla każdego kodu wprowadzać zaawansowane wzorce projektowe i kombinować przez parę dni, czy w tym miejscu lepsza jest Strategia czy Wizytator. Jeśli nie jesteś pewien, to nie stosuj żadnego wzorca – po prostu wyciągnij kod do oddzielnej klasy (lub nawet projektu) i tyle. Zajmie Ci to 20 sekund, a zyski w przyszłości mogą być ogromne. Odpowiedni wzorzec możesz wprowadzić później, gdy uznasz, że warto.

Dlaczego warto uwspólniać kod?

Jest naprawdę wiele zalet wydzielenia kodu i używania go w wielu miejscach:

  • Łatwe utrzymanie kodu – dzięki temu, że mamy kod w jednym miejscu, bardzo łatwo naprawiać błedy i dodawać nowe funkcje. Naprawiając buga, wiemy, że jest on naprawiony w każdym miejscu programu. W przypadku, gdyby kod był skopiowany, to musielibysmy pamiętać o tym, żeby w każdym miejscu kod naprawić.
  • Spójne działanie aplikacji – kolejna duża zaleta. Wyobraź sobie, że piszesz kod wyświetlający powiadomienie użytkownikowi. Gdy wydzielisz go do osobnej klasy (metody) i korzystasz z niej w każdym miejscu, w którym wyświetlasz powiadomienie, masz gwarancje, że zachowanie i wygląd programu jest spójne. Po miesiącu chcesz zmienić, kolor tła? Nie ma problemu, kod masz w jednym miejscu.

Kompozycja czy dziedziczenie?

Nie będę w tym miejscu opisywał czym jest kompozycja i dziedziczenie. W Internecie znajdziesz wiele artykułów, które opiszą to lepiej ode mnie. Chciałbym się skupić tylko na tym, czy wydzielając kod lepiej robić to korzystając z kompozycji, czy może dziedziczenia?

Generalnie kompozycja ma więcej plusów. Samo to, że dziedzicząc po jakiejś klasie, zamykamy sobie drogę do dziedziczenia po innej (klasy w C# mogą dziedziczyć tylko po jednej klasie bazowej), pokazuje ograniczenia tego podejścia. To sprawią, że kompozycja jest bardziej „luźna” lub elastyczna.

W przypadku jednak SoloProgramisty, jest to szczegół. Najważniejsze jest uwspólnienie kodu, a metoda schodzi na drugi plan. W swoich projektach korzystam z dziedziczenia oraz kompozycji i w 90% przypadków, z dziedziczeniem nie ma żadnych problemów. A te pozostałe 10% zawsze można zrefaktorować później – najważniejsze, że kod już masz wydzielony, więc zmiana podejścia będzie o wiele prostsza.

Przykłady uwspólnienia kodu

Poniżej znajdziesz parę przykładów z naszej testowej aplikacji (oraz z easyRenti), w jaki sposób, już na starcie projektu, wydzieliłem pewnie części aplikacji

Klasa bazowa dla wszystkich Command i Queries

Tego typu uwspólnienie zwykle warto zrobić od razu. Jeśli wiemy, że w naszej aplikacji będzie wiele klas (handlerów), które mają to samo przeznaczenie, to na 90% będą miały wspólne elementy.

Weźmy na przykład klasę CreateNoteHandler (z projektu NoteBookApp.Logic)

public class CreateNoteHandler : HandlerBase<CreateNoteCommand>
{

    public CreateNoteHandler(ISession session, SecurityInfo securityInfo, IMapper mapper, IDateTimeProvider dateTimeProvider) : base(session, securityInfo, mapper, dateTimeProvider)
    {
    }

    protected override async Task Execute(ISession session, CreateNoteCommand param)
    {
        var dbNote = new Note();
        dbNote.CreatedDateTime = _dateTimeProvider.UtcNow;
        dbNote.Content = param.Content;
        dbNote.User = _securityInfo.User;
        await session.SaveAsync(dbNote).ConfigureAwait(false);
    }
}

Widzimy, że dziedziczy po HandlerBase

    public abstract class HandlerBase<T> : IRequestHandler<T> where T : IRequest
    {
        private readonly ISession _session;
        protected readonly SecurityInfo _securityInfo;
        protected readonly IDateTimeProvider _dateTimeProvider;
        protected readonly IMapper _mapper;
        protected HandlerBase(ISession session, SecurityInfo securityInfo, IMapper mapper, IDateTimeProvider dateTimeProvider)
        {
            _session = session;
            _securityInfo = securityInfo;
            _mapper = mapper;
            _dateTimeProvider = dateTimeProvider;
        }
        

        protected abstract Task Execute(ISession session, T param);

        protected virtual Task AfterHandle(ISession session)
        {
            return Task.CompletedTask;
        }

        public async Task<Unit> Handle(T request, CancellationToken cancellationToken = default)
        {
            using (var trans = _session.BeginTransaction())
            {
                await Execute(_session, request).ConfigureAwait(false);
                await trans.CommitAsync(cancellationToken).ConfigureAwait(false);
            }

            await AfterHandle(_session).ConfigureAwait(false);
            return Unit.Value;
        }
    }

Ta klasa jest odpowiedzialna za odpalanie kodu naszej logiki biznesowej. Początkowo jest odpowiedzialna za rozpoczęcie tranzakcji bazodanowej. Jest jednak duża szansa, że w przyszłości będzie ona robić wiele dodatkowych rzeczy. Dzięki temu, klasa CreateNoteHandler ma tylko faktyczną implementację tworzenia notatki – wszystkie dodatkowe czynności (tworzenie sesji, tranzakcje itp) są w innym miejscu.

Klasa bazowa do komunikacji z WebApi

Następny przykład dotyczy aplikacji Blazor (projekt NoteBookApp.Client). W tym przypadku wiemy, że nasza aplikacja kliencka będzie musiała komunikować się z naszym WebApi za pomocą klasy HttpClient. Rozsądnie jest wydzielić część wspólną. Jest nią klasa bazowa DataServiceBase:

    public abstract class DataServiceBase
    {
        private readonly HttpClient _httpClient;

        protected DataServiceBase(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }

        protected JsonSerializerOptions CreateOptions()
        {
            var options = new JsonSerializerOptions()
            {
                PropertyNameCaseInsensitive = true
            };
            return options;
        }

        protected string GetUrl(string urlPart, object? param = null)
        {
            var query = param?.ToQueryString();
            var url = urlPart;
            if (!string.IsNullOrEmpty(query))
            {
                url += "?" + query;
            }
            return url;
        }

        protected async Task<T> Execute<T>(Func<HttpClient, Task<T>> method)
        {
            T? res = default;
            try
            {
                res = await method(_httpClient).ConfigureAwait(false);
                if (res is HttpResponseMessage response)
                {
                    if (!response.IsSuccessStatusCode)
                    {
                        await ConvertToException(response).ConfigureAwait(false);
                    }
                }
            }
            catch (AccessTokenNotAvailableException e)
            {
                e.Redirect();
            }
            catch (HttpRequestException e)
            {

                convertToException(e.StatusCode, e.Message);
            }

            return res!;
        }
        

        protected async Task ConvertToException(HttpResponseMessage response)
        {
            var message = await response.Content.ReadAsStringAsync();
            var errorDetails = JsonConvert.DeserializeObject<ErrorDetails>(message);
            convertToException(response.StatusCode, errorDetails?.Message, errorDetails?.ServiceError);
        }

        private void convertToException(HttpStatusCode? statusCode, string? message, ServiceError? error = ServiceError.Unknown)
        {
            error ??= ServiceError.Unknown;
            message ??= "Unknown exception occured";
            switch (error)
            {
                case ServiceError.ObjectNotFoundException:
                    throw new ObjectNotFoundException(message);
                case ServiceError.ConstraintException:
                    throw new ConstraintException(message);
                case ServiceError.UniqueException:
                    throw new UniqueException(message);
                case ServiceError.UnauthorizedAccessException:
                    throw new UnauthorizedAccessException(message);
                case ServiceError.ArgumentNullException:
                    throw new ArgumentNullException(message);
                case ServiceError.InvalidOperationException:
                    throw new InvalidOperationException(message);
                case ServiceError.ArgumentOutOfRangeException:
                    throw new ArgumentOutOfRangeException(message);
                
            }
            switch (statusCode)
            {
                case HttpStatusCode.NotFound:
                    throw new ObjectNotFoundException(message);
                case HttpStatusCode.Unauthorized:
                    throw new UnauthorizedAccessException(message);
            }
            throw new Exception(message);
        }
    }

Odpowiada ona za następujące elementy:

  • Wspólne ustawienia serializacji JSON – metoda CreateOptions()
  • Serializacja parametrów do URL – metoda GetUrl(). Mając jedną metodę, która buduje adres WebApi, możemy bardzo łatwo wprowadzać zmiany w jej działaniu oraz w sposobie, w jaki dodajemy parametry.
  • Obsługę wyjątków – metody Execute, ConvertToException, convertToException. W tej klasie zaimplementowałem translację informacji o błedach zwracanych z WebApi w formie HttpStatus i klasy ErrorDetails, na wyjątki C#.
  • Wspólne miejsce komunikacji z WebApi – w metodzie Execute możemy w przyszłości dodać dodatkowe operacje, które mają być uruchomione przed i po wysłaniu requestu do naszego serwisu. Wydzielenie tej metody trwało minutę, a w przyszłości może nam bardzo ułatwić życie.

Przygotowanie pod testy jednostkowe

O testach jeszcze będę pisał, jednak już w tym miejscu wspomnę o ważnej zmianie w kodzie, która jest bardzo łatwa do wprowadzenia na początku, a zdecydowanie ułatwi nam życie w przyszłości. Chodzi mianowicie o korzystanie z daty i czasu w naszym programie. Standardowo służy do tego DateTime.UtcNow. Ta metoda zwraca aktualny czas w UTC. Jednak wykorzystując ją bezpośrednio, możemy mieć następujące problemy:

Testy jednostkowe

Nasze testy automatyczne nie powinny zależeć od bieżącego czasu. Weźmy na tapet następujący przykład:

[Test]
public async Task AddNote()
{
    var command = new CreateNoteCommand();
    command.Content= "test content;

    await RunWithoutTransaction(async s =>
    {
        var handler = new CreateNoteHandler(s);
        await handler.Handle(command);
    });

    await Run(async s =>
    {
        var notes= await s.QueryOver<Note>().ListAsync();
        Assert.AreEqual(DateTime.UtcNow, notes[0].CreatedDateTime);
    });
}

Powyższy test sprawdza, czy operacja CreateNote stworzy obiekt Note i ustawi czas CreatedDateTime na DateTIme.UtcNow. Niestety ten kod jest błędny, gdyż w teorii DateTime.UtcNow w trakcie tworzenia obiektu Note, a potem przy sprawdzaniu, może wskazywać już inny czas (i nasz test nie przejdzie).

Co można z tym zrobić? Stworzyć interface IDateTimeProvider i dwie jego implementacje:

public interface IDateTimeProvider
{
    DateTime UtcNow { get; }
}

public class StandardDateTimeProvider:IDateTimeProvider
{
    public DateTime UtcNow => DateTime.UtcNow;
}

public class MockDateTimeProvider : IDateTimeProvider
{
    private DateTime? _utcNow;

    public MockDateTimeProvider()
    {
       _utcNow = DateTime.UtcNow;
    }
    public MockDateTimeProvider(DateTime utcNow)
    {
       _utcNow = utcNow;
    }

    public DateTime UtcNow
    {
        get
        {
            if (_utcNow.HasValue)
            {
                return _utcNow.Value;
            }

            return DateTime.UtcNow;
         }
         set
         {
            _utcNow = value;
         }
    }
}

Teraz wystarczy, aby w każdym miejscu aplikacji (np w naszym commandzie CreateNoteHandler używać naszego interface’u):

public class CreateNoteHandler : HandlerBase<CreateNoteCommand>
{
    public CreateNoteHandler(ISession session, SecurityInfo securityInfo, IMapper mapper, IDateTimeProvider dateTimeProvider) : base(session, securityInfo, mapper, dateTimeProvider)
   {
   }

   protected override async Task Execute(ISession session, CreateNoteCommand param)
   {
       var dbNote = new Note();
       dbNote.CreatedDateTime = _dateTimeProvider.UtcNow;
       dbNote.Content = param.Content;
       dbNote.User = _securityInfo.User;
       await session.SaveAsync(dbNote).ConfigureAwait(false);
   }
}

W przypadku programu, wstrzykujemy implementację StandardDateTimeProvider, natomiast w testach wykorzystujemy MockDateTimeProvider i nasz test wyglądałby tak:

[Test]
public async Task AddNote()
{
     var dateTimeProvider = new MockDateTimeProvider(new DateTime(2001,10,1));

     var command = new CreateNoteCommand();
     command.Content= "test content;

     await RunWithoutTransaction(async s =>
     {
         var handler = new CreateNoteHandler(s);
         await handler.Handle(command);
     });

     await Run(async s =>
     {
         var notes= await s.QueryOver<Note>().ListAsync();
         Assert.AreEqual(_dateTimeProvider.InstantNow, notes[0].CreatedDateTime);
     });
}

Na początku tworzymy instancję klasy MockDateTimeProvider i ustawiamy ją na dowolny czas, dzięki temu mamy gwarancję, że każdy kod korzystający z interface’u IDateTimeProvider otrzyma dokładnie tą samą datę.

Podróże w czasie

Ciekawym efektem ubocznym wprowadzenia IDateTimeProvider do naszego projektu jest możliwość podróżowania w czasie małym kosztem. Oczywiście nie mam na myśli przenoszenie użytkownika w czasy średniowiecza (nie było wtedy komputerów, które by nasz program uruchomiły 😉 ), a raczej bardzo łatwe dodanie ficzera, dzięki któremu można zobaczyć stan programu z dowolnego dnia.

Gdybyśmy używali bezpośrednio DateTime.UtcNow, to nasz program zawsze korzystałby z bieżącego dnia do wszystkich operacji i żeby móc ten czas nadpisać w całej aplikacji, wymagałoby to sporo pracy i przekazywania do każdej metody parametru z datą. W przypadku wykorzystania naszego interface’u, nie ma takiej potrzeby. Jak chcemy, żeby aplikacja działała z konkretnym czasem, to zamiast StandardDateTimeProvider, korzystamy z MockDateTimeProvider i ustawiamy go na wybrany czas.

Podsumowanie

Początek projektu jest idealnym momentem, aby wprowadzić pewne usprawnienia bardzo małym kosztem, a które znacząco mogą ułatwić nam pracę w przyszłości. Wprowadzenie powyższych rozwiązań w działającym już programie, wymagałoby sporo wysiłku, dlatego im wcześniej to zrobimy tym lepiej.

Share