Z czego składa się architektura? Z modułów i warstw. To jest intuicyjny podział wynikający z potrzeby dzielenia złożonych problemów na mniejsze i grupowania podobnych zadań. Jednak o ile co do samego istnienia warstw i modułów nikt nie ma zastrzeżeń, to strategie ich wydzielania zależą często tylko od naszej fantazji. Dzisiaj zdefiniujemy sobie czym są owe moduły i warstwy oraz w jaki sposób powinny być wydzielane. Od razu uprzedzam, że będę tutaj mówił o architekturze dużego systemu realizującego wiele zadań jednocześnie. Duża część projektów embedded jest na tyle prostych, że tak skomplikowana architektura byłaby w nich jedynie obciążeniem.
Moduły
Praktycznie każda architektura aplikacji dzieli nasz system na pewne moduły. Grupują one kod odpowiedzialny za jakieś konkretne zadanie np. obsługę wyświetlacza, czy sterowanie przyciskami. Moduły służą nam do ukrycia przed resztą aplikacji pewnych szczegółów implementacyjnych związanych z wykonywanym zadaniem. Udostępniają tylko pewien zewnętrzny interfejs (API). Powinien się on składać z minimalnej ilości funkcji i danych wystarczających do realizacji zadań przez resztę aplikacji. Dzięki temu użycie takiego modułu jest proste i zmniejszamy w ten sposób ilość potencjalnych błędów. Moduły realizują więc zadanie enkapsulacji.
Moduły powinny spełniać zasadę pojedynczej odpowiedzialności (Single Responsibility Principle – SRP). SRP jest najlepiej znana z zasad SOLID, gdzie odnosi się do klas i metod, ale ma ona dużo szersze zastosowanie i powinniśmy jej się również trzymać podczas pisania testów, commitów w Gicie, czy właśnie wydzielając moduły. Każdy moduł ma skupiać się na realizacji jednego zadania. Nie ukrywajmy sztucznie złożoności – jeżeli moduł pełni więcej funkcji, lepiej jawnie ją wydzielić. Co z tego, że na schemacie stworzymy iluzję prostoty, jeżeli moduł jest wewnętrznie złożony? W ten sposób utrudniamy sobie nie tylko pracę z kodem, ale także na przykład estymowanie zadań.
Większość kodu embedded powstaje w C, gdzie nie mamy wbudowanej w język obiektowości. Myślenie o modułach i odpowiedzialnościach może nam dlatego przychodzić ciężej. W C moduł to najczęściej zbiór funkcji działający na wspólnych danych i wykonujących to samo zadanie – taki odpowiednik klasy. Moduły mogą mieć różne poziomy abstrakcji, a także jeden moduł może składać się z wielu submodułów. Znajomość wewnętrznej struktury często nie jest potrzebna reszcie aplikacji i powinniśmy ją ukryć. Najłatwiej jest to zrobić umieszczając interfejs publiczny w jednym pliku h. Pozostałe pliki h są wtedy tylko do użycia wewnątrz tego modułu.
Warstwy
Moduły na podobnym poziomie abstrakcji są najczęściej grupowane w warstwy. Na przykład drivery sprzętowe umieszczamy w warstwie HAL (Hardware Abstraction Layer). W aplikacjach z GUI popularne jest wykorzystanie warstw MVC (Model, View, Controller). Główna idea warstw jest taka, że moduły na każdej warstwie mogą wchodzić w interakcję tylko z modułami z tej samej warstwy, albo wyższych (często istnieje dodatkowe ograniczenie jedynie do warstwy o jeden poziom wyżej). Natomiast jeżeli niższa warstwa chce przekazać jakieś dane wyżej, musi skorzystać z mechanizmu odwracania zależności zgodnie z udostępnionym przez wyższą warstwę interfejsem.
Z jakich warstw powinna się składać aplikacja embedded? Mi się bardzo podoba podział zaczerpnięty z Domain Driven Design (DDD), o którym mówi również Uncle Bob w książce „Czysta Architektura”. Już wcześniej miałem do czynienia z koncepcjami tam przedstawianymi (z różnym skutkiem), ale DDD i Uncle Bob nadali temu przejrzystą strukturę. Główne warstwy systemu to:
- Domena – zawiera naszą logikę biznesową niezaśmieconą szczegółami implementacyjnymi takimi jak peryferia procesora, czy zewnętrzne biblioteki. Czyli na przykład jeżeli robimy sterownik do inteligentnego domu, tutaj umieścimy algorytmy sterowania oświetleniem, czy ogrzewaniem. Domena nie zna szczegółów innych warstw. Zamiast tego definiuje interfejsy, które przez te niższe warstwy mają być implementowane.
- Aplikacja – jest łącznikiem między warstwą domeny a szczegółową implementacją. To tutaj umieścimy na przykład wątki do uruchomienia na RTOSie, czy procedury przerwań. Warstwa aplikacji nie zawiera skomplikowanej logiki, zajmuje się raczej inicjalizacją i przekazywaniem danych do/z obiektów pozostałych warstw
- Infrastruktura – to wszystkie zewnętrzne biblioteki, drivery sprzętowe, sterowniki do konkretnych chipów i tym podobne. To właśnie tutaj znajdują się szczegóły implementacyjne, które chcieliśmy oddzielić od pozostałych warstw.
- Prezentacja – odpowiada za wysyłanie i odbieranie danych od użytkownika, czy z zewnętrznych systemów.
Każda z tych warstw zawiera niezależne od siebie moduły, a także może dzielić się na podwarstwy.
Wydzielenie interfejsów w C może być problematyczne. Często w tym celu stosuje się callbacki, czyli wskaźniki na funkcje. Można również stosować jako interfejs zwykłe funkcje, ale nie jest to sposób wspierany przez język, wymaga więc dyscypliny od stosujących go developerów.
Istnieją dwa podejścia do wydzielania modułów – albo każdy moduł należy do pojedynczej warstwy, albo jeden moduł skupia elementy wszystkich warstw związanych z tym samym zadaniem np. sprzętowy sterownik PWM i IO, driver silnika i profiler prędkości jako pojedynczy moduł silnika.
Podsumowanie
To tyle jeżeli chodzi o moje podejście do warstw i modułów. O ile same pojęcia są znane większości inżynierów embedded, to już sposób ich stosowania jest bardzo różny. Bardzo często podział na warstwy i moduły występuje jedynie na papierze. Szczególnie często łamana jest zasada dotycząca braku zależności od niższych warstw. Jeżeli się jej nie trzymamy, cała idea architektury warstwowej zostaje zaprzepaszczona i po jakimś czasie otrzymujemy kod poprzeplatany zależnościami od szczegółów implementacyjnych.
W kolejnej części będzie o powstawaniu architektury i jej ewolucji wraz z rozwojem projektu.
30 stycznia 2019 at 11:55
A masz może linki do jakichś aplikacji napisanych zgodnie z prawidłami? Chętnie bym zajrzał w kod.
30 stycznia 2019 at 18:31
Ja za dobrze napisane aplikacje z podziałem uważam np example od Nordica do aplikacji BLE. Chociaż też nie idealnie – bo wiadomo, że producent nie będzie dbał o to, żeby jego rzeczy można było przenieść na rzeczy innego producenta, więc w kilku miejscach aż się prosi o jakieś wrapery pomiędzy warstwami.
Ale faktycznie jakoś więcej by się przydało 🙂
30 stycznia 2019 at 18:55
No właśnie z tymi przykładami jest dosyć ciężko. Domowe projekty zwykle nie są na tyle duże, żeby taką architekturę tam pakować. Dużych open sourców do embedded jest niewiele i szczerze mówiąc nie analizowałem ich na tyle, żeby coś polecić. A z projektami komercyjnymi wiadomo – zwykle kod nie jest publiczny. Ja testując różne podejścia do architektury bawię się w jakieś mini projekciki, ale one są zwykle nastawione na zdobycie wiedzy o danej koncepcji, a nie na dowiezienie projektu do końca. Dlatego też nie zawsze są miarodajne. W każdym razie będę chciał do tej serii o architekturze dodać trochę kodu, bo gadać to sobie można do woli, ale prawdziwe projekty zawsze dostarczają jakiś nieprzewidzianych sytuacji, kompromisów itp.
31 stycznia 2019 at 21:16
Otóż to! W takim razie pozostaje mi czekać na kolejne „odcinki” 🙂