Architektura heksagonalna - niezbędny składnik profesjonalnych rozwiązań serverless


Architektura heksagonalna

Zajmuję się serverless od 2016 roku. Bacznie przyglądam się temu jak się rozwija, w którym kierunku podąża, w końcu jak ewoluują usługi serverless. Widziałem mnóstwo kodu, dziesiątki rozwiązań. Przeczytałem setki artykułów (może już tysiące?), kilka książek i przeszedłem przez “pare” szkoleń. Napisałem kilka własnych, wdrożonych komercyjnie rozwiązań.

Jeśli miałbym wymienić pojedynczą rzecz, która wywarła największy wpływ na to jak programuję moje funkcje Lambda to będzie to niewątpliwe architektura heksagonalna. W mojej opinii doskonale adresuje popularne problemy pojawiające się przy pisaniu funkcji Lambda. Wprowadza naturalny ład, ułatwia podział kodu na niezależne moduły i znakomicie poprawia testowalność budowanego rozwiązania.

Co więcej, daje to ciężkie do opisania poczucie, że tworzymy dobry, czysty kod klasy enterprise. Jest przeciwieństwem skrypciarstwa, które praktycznie zawsze widzimy w publikowanych przykładach typu hello-world.

Czym jest architektura heksagonalna?

Bywa ona nazywa sześciokątną ale funkcjonuje również pod drugą nazwą: portów i adapterów. Nie lubię się powtarzać, więc zamieszczam jedną z lekcji ze swojego szkolenia poniżej.

Teraz już znasz teorię, ale jak ją zastosować w praktyce? To jest zazwyczaj najtrudniejszy etap nauki. Zatem przejdźmy do konkretów.

Funkcja lambda w stylu skrypciarskim

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const axios = require('axios')
const AWS = require('aws-sdk')

const handler = async (event) => {

// Krok 1
// Pobierz dane z publicznego API w internecie
const url = 'https://jakies-publicnze-rest-api'
const dane = await axios.get(url) // dane sa w postaci JSONa

// Krok 2
// Zapisz faktyczne dane jako plik w wiaderku S3
const s3 = new AWS.S3()
const params = {
Bucket: 'nazwa-wiaderka',
Key: 'nazwa-pliku',
Body: JSON.stringify(dane),
};
await s3.upload(params).promise();

// Krok 3
// Wysłanie notyfikacji o zapisie pliku
const tytul = '...'
const tresc = 'Plik zastał poprawnie zapisany'
const sns = new AWS.SNS()
const params = {
Message: tresc,
Subject: tytul,
TopicArn: process.env.snsTopicNotificationArn
};

await sns.publish(params).promise()
// koniec funkcji
return response;
}

module.exports = {
handler
}

Taki kod można często znaleźć w tutorialach i na stackoverflow.

Problem z tym kodem jest taki, że to jeden ciąg instrukcji. Oczywiście możemy go sobie podzielić na osobne funkcje JavaScript ale nadal pozostanie totalnie nietestowalny i będzie łamał mnóstwo dobrych praktyk w tym przede wszystkim SRP.

Taki kod jest nietestowalny, ponieważ odwołuje się bezpośrednio do usług AWS. Dodatkowo, wszystko w nim dzieje się w jednej funkcji(procedurze, metodzie). Na przykład nie możemy przetestować indywidualnie zapisu do bazy danych.

Modularyzacja na ratunek

Co sprytniejsi programiści podzielą taki kod na osobne funkcje i umieszczą je w osobnych plikach. Dzięki czemu SRP jest spełnione ✔️ i wszystko gra.

No nie do końca. Teraz kod będzie wyglądał tak.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const pobierzDane = require('pobierzDane')
const zapiszPlikS3 = require('zapiszPlikS3')
const wyslijEmail = require('wyslijEmail')

const handler = async (event) => {
// Krok 1
// Pobierz dane z publicznego API w internecie
const url = 'https://jakies-publicnze-rest-api'
const dane = await pobierzDane(url)

// Krok 2
// Zapisz faktyczne dane jako plik w wiaderku S3
await zapiszPlikS3(dane)

// Krok 3
// Wysłanie notyfikacji o zapisie pliku
const tytul = '...'
const tresc = 'Plik zastał poprawnie zapisany'
await wyslijEmail(tytul, tresc)

// koniec funkcji
return true;
}

module.exports = {
handler
}

Nasza funkcja wygląda dużo lepiej ale tylko wygląda. Nadal jest totalnie nietestowalna. Jedyne co zrobiliśmy to zamietliśmy problem pod dywan.

Żeby nie było wątpliwości, oczywiście taka funkcja będzie działać prawidło i wykona się poprawnie. Jednak pisanie kodu w taki sposób powoduje, że na dłuższą metę jest on ciężki do rozwoju i utrzymania.

Jeśli tak piszesz to się nie przejmuj! Od dziś możesz się poprawić! Ja też tak pisałem. Nie jedną, nie dwie ale dziesiątki funkcji. Dlatego wiem jak to boli jak się zajrzy do tak napisanego kodu po dwóch latach i trzeba coś zmienić. To nie jest miłe doświadczenie.

Zatem, jak pisać funkcje Lambda poprawnie?

Architektura heksagonalna na ratunek

Patrząc na nasz kod z perspektywy architektury heksagonalnej mamy logikę biznesową, którą można opisać jako scenariusz kroków:

  1. Pobierz dane
  2. Zapisz same dane
  3. Wyślij notyfikacje

Mamy też trzy porty:

  1. Port służący pobieraniu danych
  2. Port służący do zapisu danych
  3. Port służący do wysłania notyfikacji

Logika biznesowa nie powinna się interesować gdzie trzymamy dane z punktu 2.

To jest rola konkretnej implementacji, którą nazywamy adapterem. To może być wiaderko S3, to może być dysk twardy, to może być serwer SFTP. Cokolwiek. W ekstremalnym przypadku człowiek mógłby wydrukować te dane na papierze i wsadzić do szafki, aby wypełnić ten krok w procesie biznesowym. Dobrze, tutaj już się zbliżamy do Analizy Biznesowej, więc może wróćmy do sedna 😉

Jak napisać funkcję Lambda w stylu architektury heksagonalnej?

Bardzo prosto. 😃

Po pierwsze musimy logikę biznesową wyciągnąć z handlera funkcji Lambda, który to jest nam narzucony przez kontrakt tej usługi. Tworzymy po prostu nową metodę businessLogic (funkcja JavaScript nie mylić z funkcją Lambda), która zostanie wywołana przez handler w rzeczywistym działaniu w chmurze AWS. Ten zabieg pozwoli nam oderwać logikę biznesową od implementacji.

Po drugie do nowej metody businessLogic, przekazujemy zależności: adaptery do naszych portów. Jak powiedziałem wcześniej logiki biznesowej nie interesuje konkretna implementacja. Ona tylko koordynuje działanie podległych jej portów.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const pobierzDaneAdapter = require('pobierzDaneAdapterRest')
const zapiszPlikAdapter = require('zapiszPlikAdapterS3')
const notyfikujAdapter = require('wyslijNotyfikacjeAdapterSns')

const handler = async (event) => {
return businessLogic(pobierzDaneAdapter, zapiszPlikAdapter, notyfikujAdapter)
}

const businessLogic = async (pobierzDane, zapiszPlik, notyfikuj) => {
// Krok 1
// Pobierz dane z publicznego API w internecie
const url = 'https://jakies-publicnze-rest-api'
const dane = await pobierzDane(url)

// Krok 2
// Zapisz faktyczne dane jako plik
await zapiszPlik(dane)

// Krok 3
// Wysłanie notyfikacji o zapisie pliku
const tytul = '...'
const tresc = 'Plik zastał poprawnie zapisany'
await notyfikuj(tytul, tresc)

// koniec funkcji
return true;
}

module.exports = {
handler
}

Gdybyśmy mieli w JavaScript interfejsy to moglibyśmy powiedzieć, że jest to programowanie do interfejsu. Funkcja businessLogic w linijce 9 narzuca pewne interfejsy zależnościom, z których korzysta. W linijce 9 mamy generyczne porty. Natomiast w linijce 6 przekazujemy konkretne implementacje pasujące do tych portów, czyli adaptery.

Jeśli programowałeś kiedyś w Javie to architektura heksagonalna bardzo przypomina koncept Dependency Injection. W Springu to wszystko dzieje się automatycznie, a tutaj musisz samodzielnie wstrzykiwać zależności do swoich funkcji ale koncept jest ten sam i daje te same benefity 🙂

Zalety architektury heksagonalnej w serverless

Opisane przeze mnie podejście oferuje szereg korzyści.

  1. W naturalny sposób wprowadza ład do naszego kodu. Podział na niezależne moduły staje się intuicyjny.

  2. W efekcie uzyskujemy poprawną dekompozycję funkcjonalną budowanego rozwiązania. Nasz kod zaczyna spełniać pryncypium SRP oraz pozostałe z grupy SOLID.

  3. Modularyzacja znakomicie zwiększa testowalność naszego rozwiązania. Dlaczego to ważne nie muszę chyba nikomu tłumaczyć. Teraz bez problemu możemy napisać test dla funkcji businessLogic, ponieważ w parametrach, których oczekuje możemy przekazać jej cokolwiek, co spełnia interfejs. Np. w trakcie testów możemy napisać własne mocki (to jest trywialnie proste) i nie mamy już zależności do zewnętrznych bibliotek (aws-sdk) ani usług w chmurze. Dzięki czemu nietestowalną funkcję możemy teraz przetestować testem jednostkowym!

  4. Vendor lock-in. Nawet nie chcę zaczynać tego tematu, więc tylko w telegraficznym skrócie napiszę, że takie podejście znacznie zmniejsza koszt migracji do innej chmury. Funkcja businessLogic będzie działać tak samo w Lambdzie, Azure Functions, czy dowolnej innej usłudze FaaS, jedynie musimy zmienić handler, aby go dostosować do nowego dostawcy. Posiadanie dobrych testów, też oczywiście będzie na wagę złota przy migracji.

  5. Programowanie w ten sposób powoduje, że po pewnym czasie i kilku projektach stworzymy zbiór adapterów do różnych usług z których korzystaliśmy np. do bazy danych DynamoDB, usługi S3 gdzie przechowujemy pliki, topików SNS, usług firm trzecich i tak dalej.
    W każdym następnym projekcie, możemy je ponownie wykorzystać, co mocno skraca czas potrzebny na dostarczenie rozwiązania i jeszcze bardziej umożliwia programiście skupić się na dostarczeniu wartości użytkownikowi końcowemu, a nie walce z boilerplate.

Dla wielu serverless to jeszcze totalna nowość. Dla mnie to już czasem legacy 😜 Jak pisałem posiadam rozwiązania, które działają ponad dwa lata w chmurze. Jak dla każdej innej aplikacji ich utrzymywalność jest szalenie ważna szczególnie z perspektywy organizacji.

Programowanie w opisany przeze mnie sposób powoduje, że otrzymujemy profesjonalny kod, który jest ławy w zrozumieniu i prosty w modyfikacji, również po latach od jego napisania. Nawet pisząc w języku skryptowym mogę robić to w profesjonalny sposób, stosując najlepsze praktyki, których się nauczyłem przed laty programując aplikacje biznesowe klasy enterprise.




Cześć

Nazywam się Paweł Zubkiewicz i cieszę się, że tu jesteś!
Od ponad 14 lat profesjonalnie tworzę oprogramowanie, a od 2016 roku pasjonuje się Serverless.
Tą stronę stworzyłem z myślą o Tobie i o nas wszystkich, którzy uważają, że trend serverless trwale zmieni sposób tworzenia oprogramowania.
Więcej o tej stronie...

Kategorie

Pobierz bezpłatny PDF

Poradnik 12 Rzeczy o Serverless

Wybrane artykuły