Minęło właśnie sześć tygodni od opublikowania notki omawiającej szkic architektury systemu, nad którym właśnie pracuje. Tak, jak zakładaliśmy, wchodzimy właśnie z naszym systemem w fazę testów akceptacyjnych. Wdrożenie produkcyjne zbliża się wielkimi krokami. Przy tej okazji chciałbym Wam opowiedzieć, co zmieniło się w projekcie naszego systemu w ciągu tych pracowitych sześciu tygodni.

Architektura

Większość definicji architektury odnosi się do jej niezmienności — architektura, to ta część projektu, która jest kosztowna lub trudna do zmiany w późniejszych jego etapach. Ponieważ nie przechodziliśmy przez żaden bolesny proces przeprojektowywania, mogę z całą pewnością stwierdzić, że nasza architektura pozostała niezmieniona. Na początku projektu zdefiniowałem architekturę tak:

model zakłada istnienie anemicznych struktur danych, które są przetwarzane przez wiele następujących po sobie procesów. Każdy proces jest zaś sekwencją czynności, z których każda reaguje na konkretny rodzaj zdarzenia. Efektem wykonania czynności jest opublikowanie innego zdarzenia, aktywujące kolejną czynność.

Prawda, że minimalna ta definicja? Dobra architektura powinna być właśnie minimalna, ponieważ tylko wtedy może być stabilna. Jeśli architekt robi zbyt wiele założeń, jest więcej niż pewne, że któreś z tych założeń okaże się fałszywe w toku prac nad projektem.

Infrastruktura

Celem infrastruktury jest implementacja założeń architektury. W tym obszarze, jak w każdym innym, staram się kierować zasadą YAGNI (You Ain’t Gonna Need It). Z tego powodu infrastruktura w moim projekcie zmieniała się dosyć często.

Na początku było…

Na początku w ogóle jej nie było! Przez pierwszy tydzień lub dwa skupiliśmy się na rozwoju logiki systemu. Chcieliśmy mieć działający prototyp, który przechodzi testy “na sucho” (bez odwoływania się do bazy danych itp.). W ten sposób chcieliśmy udowodnić, że model procesowy w ogóle sprawdza się w przypadku naszego problemu. Gdyby okazało się inaczej, mielibyśmy jeszcze całkiem sporo czasu na zmianę założeń.

Pierwsza wersja

Ostatecznie przyszedł czas na pierwszą wersji infrastruktury. Napisanie jej zajęło mi jakieś 2 dni. Wersja ta obsługiwała wszystkie trzy tryby łączenia zdarzeń i czynności (natychmiastowy, z checkpointem i odroczony). Nie oferowała jedna żadnych mechanizmów kontroli stanu działania (ile zdarzeń oczekuje na przetworzenie itp.) ani historii wykonania (jakie czynności zostały wykonane w kontekście danego obiektu biznesowego). Była wystarczająca do wewnętrznych testów i o to nam chodziło.

Mechanizm był banalnie prosty. Każde zakolejkowane zdarzenie było adresowane do konkretnej docelowej czynności. W momencie publikacji odszukiwane były wszystkie zasubskrybowane czynności i do kolejki odkładane było tyle kopii zdarzenia, ile znaleziono subskrybentów. Dzięki temu różne czynności mogły subskybować to samo zdarzenie z różnym opóźnieniem.

Przetwarzanie zrealizowane było w formie pętli, która w każdym obrocie pobierała pierwsze zdarzenie z kolejki, odnajdywała jego adresata, przekazywała mu obiekt zdarzenia a następnie usuwała zdarzenie z kolejki oczekujących. Efektem takiego działania mogło być opublikowanie nowych zdarzeń. Te z nich, które były subskrybowane trwale (z checkpoint‘em) odkładane były do kolejki w bazie danych. Pozostałe kolejkowane były w pamięci do natychmiastowego przetworzenia w ramach tej samej transakcji.

Historia

Pierwszym dodatkowym wymaganiem, jakie się pojawiło, było odkładanie historii przetwarzania. “Nic prostszego!” — odpowiedziałem naszemu Product Owner‘owi. Dodanie kodu wiążącego zdarzenie z obiektem zajęło mi dosłownie chwilę. Efektem ubocznym tej poprawki była zmiana kodu usuwającego przetworzone zdarzenia na kod, który oznaczał je flagą “wykonane”. Jakie to miało konsekwencje dowiecie się niedługo.

Diagnostyka

Następna zmiana została zainicjowana przeze mnie. W pierwszej wersji, do zapisu danych związanych ze zdarzeniami, użyliśmy serializacji binarnej System.Runtime.Serialization. Dlaczego? Ponieważ ten mechanizm jest wspierany przez NHibernate out-of-the-box. Niestety ma on dwie wady. Po pierwsze, rozmiar danych po serializacji jest dosyć duży. Po drugie (i gorsze), dane te są zupełnie nieczytelne, zarówno z poziomu SQL Server Management Studio, jak technologii GUI, którą zastosowaliśmy (własne rozwiązanie 4GL). Przejście na serializację JSON (z formatowaniem) rozwiązało problem diagnostyki (wykorzystaliśmy bardzo dobrą bibliotekę JSON.NET).

Wydajność

Przetwarzanie zdarzeń oparte było o jedną tabelę w bazie danych. Zdarzenia, która są gotowe do przetworzenia wyciągane były mniej-więcej takim zapytaniem:

SELECT * FROM QueuedEvent WHERE DueDate <= @currentTime AND Processed = 0

Ten drugi warunek został dodany w momencie implementacji obsługi historii. Niestety wydajność takich zapytań, wziąwszy pod uwagę ilość zdarzeń, jakie mogą się odłożyć po roku od uruchomienia systemu, nie byłaby zapewne zadowalająca. Pierwszym rozwiązaniem, jakie się nasuwa, jest partycjonowanie. Niestety w tym wypadku zdefiniowanie funkcji partycjonującej byłoby trudne. Postanowiliśmy więc rozdzielić zdarzenia na dwie osobne tabele: jedną dla zdarzeń oczekujących i jedną dla przetworzonych. Ta pierwsza zwykle będzie zawierać nie więcej niż kilkadziesiąt rekordów, więc nie ma sensu w ogóle jej indeksować. Dzięki temu częste wstawienia i usunięcia będą tańsze.

Wydajność raz jeszcze

W końcu przyszedł czas na podejście do problemu zrównoleglenia przetwarzania. Okazało się, że czas odpowiedzi zewnętrznych systemów, z którymi się integrujemy, jest bardzo długi. Miało to bardzo niekorzystny wpływ na przepustowość naszego systemu, gdyż w podczas odczekiwania na odpowiedź dla jednego zdarzenia, inne nie były przetwarzane.

Rozwiązanie jest proste — zrównoleglijmy przetwarzanie. Prościej powiedzieć niż zrobić. Ostatecznie jednak się udało. Nasze rozwiązanie składa się z podręcznej pamięci przechowującej identyfikatory zdarzeń oczekujących na przetwarzanie i wielu wątków pobierających kolejne identyfikatory. Cache automatycznie odświeża się, gdy zostanie opróżniony.

Aby nie blokować całej tabeli ze zdarzeniami w momencie pobierania danych do cache, stosujemy tam najsłabszy poziom izolacji transakcji. Konsekwencją tego jest fakt, że nie jesteśmy pewni, czy zdarzenia, których identyfikatory pobraliśmy, są jeszcze zdatne do przetworzenia. Fakt ten jest weryfikowany przed samym przetworzeniem oraz po jego zakończeniu (optimistic concurrency).

Morał

Każda historia ma swój morał. W tym wypadku chodzi o relację architektura — design. Architektura w tym projekcie była stabilna. Z drugiej strony design (czyli głównie infrastruktura) zmieniał się praktycznie cały czas.

Stabilność architektury (zaklętej w interfejsach) pozwoliła zbudować szybko całą logikę biznesową aplikacji, bez potrzeby robienia kosztownych restrukturyzacji po każdej modyfikacji infrastruktury.

Najlepsza architektura to taka, której prawie nie widać. Najlepszy design to taki, który rozwija się wraz z projektem i jego potrzebami.

VN:F [1.9.13_1145]
Rating: 2.7/5 (3 votes cast)
Sześć tygodni nietrywialnego modelu, 2.7 out of 5 based on 3 ratings