Magnus Stuhr
Magnus StuhrPrincipal Engineer @ Computas As

Jak wyeliminować powtarzające się w kodzie schematy

Poznaj różnicę między semantyczną, a strukturalną duplikacją kodu i sprawdź, Jak wyeliminować schematy powtarzające się w kodzie.
2.07.202011 min
Jak wyeliminować powtarzające się w kodzie schematy

Duplikacja kodu jest jednym z najgorszych antywzorców w inżynierii oprogramowania. Może ona doprowadzić Twój system do stanu, w którym będzie on nie do utrzymania. Fundamentalna zasada inżynierii oprogramowania, czyli “Nie powtarzaj się” (ang. DRY principle), została zapoczątkowana przez Andy'ego Hunta i Dave’a Thomasa w książce “Pragmatyczny programista. Od czeladnika do mistrza”, w której autorzy zaznaczają:

Każdy element wiedzy powinien mieć swoją własną autorytatywną i jasną reprezentację w systemie.


Zasada “Nie powtarzaj się” odnosi się do czegoś więcej niż do duplikacji kodu — można ją również odnieść do wszystkich aspektów systemu, np. modeli baz danych, systemów budowania, dokumentacji itd. Niemniej jednak duplikacja kodu to główny problem.  Pierwszy zapach kodu opisany w książce “Refactoring” Martin Fowlera to właśnie duplikacja. Razem z Kentem Beckiem mówią oni o następujących rzeczach:

W paradzie brzydko pachnącego kodu z przodu idzie zduplikowany kod. Jeśli widzisz tę samą strukturę kodu w więcej niż jednym miejscu, to możesz być pewny, że program będzie działał lepiej, jeśli to ujednolicisz. 


Oto co o duplikacji kodu mówi Robert C. Martin:

Zduplikowany kod jest przyczyną wszelkiego zła w projektowaniu oprogramowania. Gdy system jest zaśmiecany wieloma snippetami identycznego lub prawie identycznego kodu, to jest to równoznaczne z niechlujnością, niedbałością i brakiem profesjonalizmu. Eliminacja duplikatów jest niezwykle istotnym obowiązkiem developerów.


Niechlujność, niedbałość i brak profesjonalizmu to mocne słowa, ale podkreślają dobitnie, jak ważne jest, aby nie duplikować kodu. Większość inżynierów oprogramowania powinna znać i przestrzegać zasady DRY (Nie powtarzaj się), ale nadal często widzi się nieuzasadnione duplikaty. 

Pomijając przypadki, w których mamy do czynienia z oczywistą duplikacją strukturalną, którą można po prostu usunąć przez wydobycie kodu do odizolowanych metod lub klas, do ponownego użycia, to w tym artykule skupimy się na semantycznej duplikacji, czyli takiej, którą trudniej wyłapać i wyeliminować (np. zduplikowane zachowanie i schematy kodu). 

Duplikacja strukturalna vs. duplikacja semantyczna

Chciałbym wyjaśnić różnicę między powyższymi pojęciami.

Duplikacja strukturalna polega na pisaniu lub kopiowaniu instrukcji, które zostały napisane gdzieś indziej lub duplikowaniu zmiennych, czy też wartości, które reprezentują to samo. Duplikację semantyczną trudniej zauważyć i wyeliminować. Mogą to być schematy kodu, które spełniają te same wymagania, czy wykonują te same operacje, ale mają niewielkie różnice w konfiguracji, czy wywołaniu wewnętrznych metod. 

Abstrakcja nas uratuje

Podczas gdy duplikacja strukturalna jest zazwyczaj oczywista i łatwa do wyeliminowania, to duplikację semantyczną często się pomija ze względu na brak pomysłu lub wiedzy na temat tego, jak się jej pozbyć. Jest jednak jeden koncept, który rozwiąże ten problem, a jest to abstrakcja. Robert C. Martin również tak twierdzi:

Zduplikowany kod zawsze reprezentuje brakującą abstrakcję.


Abstrakcja jest przeciwieństwem konkretu. Oznacza to, że aby zwiększyć poziom abstrakcji w naszym kodzie, to musimy zredukować lub usunąć konkretne połączenia w naszym kodzie. Przykład: eliminacja zduplikowanych schematów w kodzie za pomocą luźno powiązanych abstrakcji Zanim zaczniemy, każdy krok w rozwoju tego przykładu jest oznaczony jak jakieś konkretne wydanie z numerem wersji w repozytorium kodu. Łatwo Ci więc będzie spojrzeć na konkretną implementację. Piszemy w C# / .NET Core, a repozytorium jest tutaj

Poniższy przykład ilustruje, jak zduplikowane schematy w kodzie mogą być iteracyjnie usuwane dzięki różnym poziomom abstrakcji. Przykład ten jest na tyle prosty, że finalne rozwiązanie może się wydawać przesadzone, ale chodzi o to, żebyśmy przyswoili niektóre zasady. Wyobraźcie sobie teraz przykład, w którym potrzebujemy funkcji wykonywania różnych żądań (np. POST, PUT, PATCH oraz GET) i deserializacji odpowiedzi jako typ ogólny. Różne metody powinny przyjąć jako argumenty klienta HTTP i ścieżkę żądania, wykonać żądanie i zwrócić zdeserializowaną odpowiedź jako typ ogólny.  

Wywołujący może otrzymać instancję klienta HTTP, żądanie URI oraz typ klasy Person, co powinno być deserializacją żądania HTTP. 

v0.1.0 - zduplikowany schemat kodu

Kod można zobaczyć tutaj

Szkic kodu takiej funkcji wyglądałby tak: 

public class HttpRequestOperator
{
    public async Task<TResponseContentType> GetAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.GetAsync(requestUri);
            var responseContentRaw = await response.Content.ReadAsStringAsync();
            responseContent = JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
        }
        catch (Exception exception)
        {
            throw new HttpRequestException("An exception occurred. See inner exception for details.", exception);
        }

        return responseContent;
    }

    public async Task<TResponseContentType> PostAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.PostAsync(requestUri, httpContent);
            var responseContentRaw = await response.Content.ReadAsStringAsync();
            responseContent = JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
        }
        catch (Exception exception)
        {
            throw new HttpRequestException("An exception occurred. See inner exception for details.", exception);
        }

        return responseContent;
    }

    public async Task<TResponseContentType> PatchAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.PatchAsync(requestUri, httpContent);
            var responseContentRaw = await response.Content.ReadAsStringAsync();
            responseContent = JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
        }
        catch (Exception exception)
        {
            throw new HttpRequestException("An exception occurred. See inner exception for details.", exception);
        }

        return responseContent;
    }

    public async Task<TResponseContentType> PutAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.PutAsync(requestUri, httpContent);
            var responseContentRaw = await response.Content.ReadAsStringAsync();
            responseContent = JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
        }
        catch (Exception exception)
        {
            throw new HttpRequestException("An exception occurred. See inner exception for details.", exception);
        }

        return responseContent;
    }
}

HttpRequestOperator v0.1.0


Widzimy tutaj, że metody są prawie identyczne. Oba wykonują zawartość żądania HTTP i wyrzucają HttpRequestException, jeśli podczas wykonywania nastąpi błąd. Istnieje tylko jedna rzecz, dzięki której powyższe metody się różnią — chodzi o wykonanie poszczególnych metod HTTP. Jak możemy pozbyć się duplikatu, kiedy schemat wywołuje wewnętrznie zupełnie różne metody? O tym później, najpierw jednak zbadajmy strukturalną duplikację w tym kodzie. 

v0.1.1 - wyeliminuj strukturalną duplikację kodu

Kod dostępny tutaj

W v0.1.0 istnieją strukturalne duplikaty, ponieważ różne metody używają tego samego kodu do pobierania i deserializacji odpowiedzi HTTP oraz wyrzucania tego samego wyjątku z tak samo zakodowaną wiadomością. Ponieważ nie zawsze łatwo można dany duplikat usunąć, wielu programistów wyeliminowałaby strukturalną duplikację i została z czymś takim: 

public class HttpRequestOperator
{
    public async Task<TResponseContentType> GetAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.GetAsync(requestUri);
            responseContent = await DeserializeContent<TResponseContentType>(response);
        }
        catch (Exception exception)
        {
            throw CreateHttpRequestException(exception);
        }

        return responseContent;
    }

    public async Task<TResponseContentType> PostAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.PostAsync(requestUri, httpContent);
            responseContent = await DeserializeContent<TResponseContentType>(response);
        }
        catch (Exception exception)
        {
            throw CreateHttpRequestException(exception);
        }

        return responseContent;
    }

    public async Task<TResponseContentType> PatchAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.PatchAsync(requestUri, httpContent);
            responseContent = await DeserializeContent<TResponseContentType>(response);
        }
        catch (Exception exception)
        {
            throw CreateHttpRequestException(exception);
        }

        return responseContent;
    }

    public async Task<TResponseContentType> PutAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpClient.PutAsync(requestUri, httpContent);
            responseContent = await DeserializeContent<TResponseContentType>(response);
        }
        catch (Exception exception)
        {
            throw CreateHttpRequestException(exception);
        }

        return responseContent;
    }

    private static async Task<TResponseContentType> DeserializeContent<TResponseContentType>(
        HttpResponseMessage response)
    {
        var responseContentRaw = await response.Content.ReadAsStringAsync();

        return JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
    }

    private static HttpRequestException CreateHttpRequestException(Exception innerException)
    {
        return new HttpRequestException("An exception occurred. See inner exception for details.", innerException);
    }
}

HttpRequestOperator v0.1.1

v0.2.0 - wstępna eliminacja zduplikowanego schematu w kodzie 

Kod dostępny tutaj

Z pewnością widać, że kod w v0.1.1 jest lepszy niż ten z v0.1.0, ale ciągle używamy tego samego schematu. Aby to wyeliminować, musimy spojrzeć na różnice. Wykonanie różnych requestów HTTP podąża za tym samym kontraktem, w którym wszystkie zwracają wiadomość z odpowiedzi HTTP. Możemy sparametryzować te żądania jako funkcje i przekazać je jako argumenty do jednej metody zawierającej całą operację, która jest dostępna do ponownego użycia: 

public class HttpRequestOperator
{
    public async Task<TResponseContentType> GetAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient)
    {
        Task<HttpResponseMessage> LoadHttpResponse() => httpClient.GetAsync(requestUri);

        return await RequestAsync<TResponseContentType>(LoadHttpResponse);
    }

    public async Task<TResponseContentType> PostAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        Task<HttpResponseMessage> LoadHttpResponse() => httpClient.PostAsync(requestUri, httpContent);

        return await RequestAsync<TResponseContentType>(LoadHttpResponse);
    }

    public async Task<TResponseContentType> PatchAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        Task<HttpResponseMessage> LoadHttpResponse() => httpClient.PatchAsync(requestUri, httpContent);

        return await RequestAsync<TResponseContentType>(LoadHttpResponse);
    }

    public async Task<TResponseContentType> PutAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        Task<HttpResponseMessage> LoadHttpResponse() => httpClient.PutAsync(requestUri, httpContent);

        return await RequestAsync<TResponseContentType>(LoadHttpResponse);
    }

    public async Task<TResponseContentType> RequestAsync<TResponseContentType>(
        Func<Task<HttpResponseMessage>> loadHttpResponse)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await loadHttpResponse.Invoke();
            var responseContentRaw = await response.Content.ReadAsStringAsync();
            responseContent = JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
        }
        catch (Exception exception)
        {
            throw new HttpRequestException("An exception occurred. See inner exception for details.", exception);
        }

        return responseContent;
    }
}

HttpRequestOperator v0.2.0

Wyeliminowaliśmy zduplikowany schemat, przekazując różne żądania HTTP jako funkcje do jednej metody zawierającej nasz schemat kodu. W przypadku implementacji niskopoziomowych, takich jak dostęp do konkretnych baz danych, wydaje mi się, że należy zostawić kod właśnie w taki sposób. 

Niemniej jednak jeżeli chodzi o rzeczy wysokopoziomowe (np. zasady biznesowe), kod jest zdecydowanie zbyt mocno powiązany z tym, jak wykonuje się dany request. W takim przypadku kod powinien celować w wysoki poziom abstrakcji. Nasz przykład jest również bardzo prosty, dzięki czemu łatwo zrozumieć przekazywanie funkcji do metody i co następnie się z nią dzieje. 

Funkcje nie są jednak semantycznie modelowane w takim sensie, że zapewniają informacje o typach przyjmowanych i zwracanych. Niemożliwym jest jednak, aby wiedzieć co te argumenty i zwracane typy reprezentują. Nie mamy w tym przypadku żadnej informacji na temat funkcji, która bierze żądanie URI jako argument — definiuje po prostu, że przyjmuje ciąg znaków. 

Jeżeli chodzi o bardziej złożone scenariusze, to trudniej nam będzie podążać za kodem. Dlatego zdecydowanie preferuję podejście “od strony funkcji”, które eliminuje zduplikowany schemat postępowania zamiast zduplikowanego kodu.

v1.0.0 - intuicyjna eliminacja zduplikowanego schematu 

Kod dostępny tutaj

Spróbujmy teraz wynieść kod na wyższy poziom - jak możemy sprawić, aby przekazywane przez nas funkcje jako argumenty mogły być bardziej intuicyjnie definiowane. Ponieważ jednak nasze funkcje podążają za tym samym kontraktem, możemy to modelować, jak klasę abstrakcyjną. 

Nazwijmy tę klasę “HttpRequest” i dodajmy abstrakcyjną metodę o nazwie “Execute”. Klasa HttpRequest przyjmuje URI żądania oraz HttpClient jako nazwane argumenty do konstruktora, co sprawia, że cała akcja jest bardziej intuicyjna w porównaniu do nienazwanych argumentów funkcji, które zaimplementowaliśmy w poprzednim przykładzie.  

Zamiast przyjmować funkcję jako argument, metoda zawierająca całą logikę bierze zamiast tego instancję HttpRequest i wywołuje jej metodę “Execute”:

public class HttpRequestOperator
{
    public async Task<TResponseContentType> GetAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient)
    {
        var httpRequest = new HttpRequestGet(requestUri, httpClient);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    public async Task<TResponseContentType> PostAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        var httpRequest = new HttpRequestPost(requestUri, httpClient, httpContent);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    public async Task<TResponseContentType> PatchAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        var httpRequest = new HttpRequestPatch(requestUri, httpClient, httpContent);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    public async Task<TResponseContentType> PutAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        var httpRequest = new HttpRequestPut(requestUri, httpClient, httpContent);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    private static async Task<TResponseContentType> ExecuteRequest<TResponseContentType>(HttpRequest httpRequest)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpRequest.Execute();
            var responseContentRaw = await response.Content.ReadAsStringAsync();
            responseContent = JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
        }
        catch (Exception exception)
        {
            throw new HttpRequestException("An exception occurred. See inner exception for details.", exception);
        }

        return responseContent;
    }
}

HttpRequestOperator v1.0.0

Teraz, zamiast tworzyć funkcje przekazywane jako argumenty do metody, kod tworzy instancje klas rozszerzające klasę abstrakcyjną „HttpRequest”. Każda z tych instancji odzwierciedla jedną konkretną metodę żądania HTTP, co oznacza, że mamy jedną implementację dla POST, PUT, PATCH i GET.

Każde z tych klas żądań HTTP nadpisuje metodę „Execute” swojej superklasy „HttpRequest”, w której znajduje się ich implementacja żądania HTTP. Metoda zawierająca schemat kodu nie przejmuje się jednak tymi konkretnymi implementacjami i akceptuje dowolny typ klasy „HttpRequest”, wykorzystując polimorfizm obiektowy.

Jeśli chcesz rzucić okiem na strukturę HttpRequest i jego implementację, to zachęcam do zapoznania się z kodem dla wersji 1.0.0.

v2.0.0 - abstrakcja i rozdzielanie niskopoziomowych implementacji

Kod dostępny tutaj

W wersji 1.0.0 kod ponownie się poprawił; stał się bardziej intuicyjny oraz łatwiejszy do rozszerzenia i utrzymania. Zduplikowany schemat kodu został już wyeliminowany w przykładzie 0.2.0, więc kod pokazuje teraz, że można je wyeliminować przy jednoczesnym zachowaniu poziomu intuicyjności w kodzie.

A teraz, jeśli potraktujemy ten kod jako politykę wysoko-poziomową, zawierającą reguły biznesowe w naszym systemie, podniósłbym poziom abstrakcji jeszcze dalej. Kod nadal opiera się na konkretnych implementacjach, a różne implementacje HttpRequest są tworzone bezpośrednio w kodzie. Aby w kodzie były tylko luźne powiązania, musielibyśmy usunąć tę konkretyzację z naszego kodu.

Dokonalibyśmy wstrzyknięcia interfejsu odpowiedzialnego za tworzenie tych żądań HTTP jako factory. Następnie, pozwolilibyśmy żądaniom HTTP implementować interfejs IHttpRequest, tak aby można było włączyć inne sposoby modelowania żądań HTTP w dowolnym momencie. Kod wyglądałby tak:

public class HttpRequestOperator
{
    private readonly IHttpRequestFactory _httpRequestFactory;

    public HttpRequestOperator(IHttpRequestFactory httpRequestFactory)
    {
        _httpRequestFactory = httpRequestFactory;
    } 

    public async Task<TResponseContentType> GetAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient)
    {
        var httpRequest = _httpRequestFactory.CreateGetRequest(requestUri, httpClient);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    public async Task<TResponseContentType> PostAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        var httpRequest = _httpRequestFactory.CreatePostRequest(requestUri, httpClient, httpContent);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    public async Task<TResponseContentType> PatchAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        var httpRequest = _httpRequestFactory.CreatePatchRequest(requestUri, httpClient, httpContent);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    public async Task<TResponseContentType> PutAsync<TResponseContentType>(
        string requestUri,
        HttpClient httpClient, 
        HttpContent httpContent)
    {
        var httpRequest = _httpRequestFactory.CreatePutRequest(requestUri, httpClient, httpContent);

        return await ExecuteRequest<TResponseContentType>(httpRequest);
    }

    private static async Task<TResponseContentType> ExecuteRequest<TResponseContentType>(IHttpRequest httpRequest)
    {
        TResponseContentType responseContent;

        try
        {
            var response = await httpRequest.Execute();
            var responseContentRaw = await response.Content.ReadAsStringAsync();
            responseContent = JsonConvert.DeserializeObject<TResponseContentType>(responseContentRaw);
        }
        catch (Exception exception)
        {
            throw new HttpRequestException("An exception occurred. See inner exception for details.", exception);
        }

        return responseContent;
    }
}

HttpRequestOperator v2.0.0

Teraz kod jest całkowicie oddzielony od dowolnej implementacji. Oznacza to, że klasa HttpRequestOperator nie jest już zależna od żadnych implementacji specyficznych dla HTTP. Testy również radykalnie się zmieniają, czyli są teraz całkowicie oddzielone, a wszystkie funkcje klasy HttpRequestOperator można zamockować, co oznacza, że nie zależą one również od konkretnych implementacji HTTP.

Kod stał się teraz łatwiejszy do przetestowania, a także do rozszerzenia i utrzymania na dłuższą metę. Wszystko to obsługuje zasady „odwrócenia zależności”, „pojedynczej odpowiedzialności” i „otwarte-zamknięte”.

Podsumowanie

Przyjrzeliśmy się różnicy między strukturalnym a semantycznym powielaniem kodu i spojrzeliśmy na przykład zduplikowanego schematu kodu oraz, jak można go wyeliminować na różne sposoby. Abstrakcja jest tutaj kluczem do sprawienia, aby nasz kod był intuicyjny i luźno połączony. 

Robert C Martin twierdzi w swojej książce pod tytułem “Clean Architecture”, że:

Dobra architektura maksymalizuje liczbę niepodjętych decyzji


Oznacza to tyle, że kod i komponenty należy oddzielić poprzez abstrakcję, aby decyzje na niskim poziomie, takie jak implementacje baz danych, mogły być opóźniane tak długo, jak to możliwe, gdy wzrasta wiedza na temat rozwiązania technicznego i domeny biznesowej.

Co więcej, abstrakcja jest kluczem do wyeliminowania duplikacji kodu, podejmowania właściwych decyzji we właściwym czasie, eliminacji ryzyka i kosztów, a także do utrzymania i rozbudowy systemu tak łatwo i tanio, jak to możliwe.


Oryginał tekstu w języku angielskim przeczytasz tutaj

<p>Loading...</p>