Rust: aplikacje z API dla wtyczek

Niektóre aplikacje pozwalają użytkownikom na modyfikowanie ich funkcjonalności. W wielu przypadkach odbywa się to za pomocą wtyczek - niewielkich bibliotek, które są wczytywane przez główny program, a następnie wykorzystywane w określonych okolicznościach. Za rozpoznawalny przykład mogą tu służyć komunikatory, takie jak np. Pidgin. W ich przypadku wtyczki mogą służyć np. do obsługi różnych protokołów (Gadu-Gadu, Jabber, Facebook...), modyfikacji wyglądu czy dodawania nowych funkcji do komunikatora. W symulatorze Orbiter można w formie wtyczek dodawać np. nowe statki kosmiczne. Potencjalnych zastosowań jest mnóstwo. W tej notce zamierzam zaprezentować, jak podobny efekt można osiągnąć w języku Rust. Mój sposób nie jest zapewne ani jedynym możliwym, ani najlepszym, wydaje mi się jednak prosty i wygodny :)

Słowo wstępu

Głównym mechanizmem, który wykorzystamy, są obsługiwane przez Rust tzw. cechy (ang. traits). Cechy określają funkcje, obsługiwane przez daną strukturę danych. Np. liczbowe typy danych implementują cechę Add, która umożliwia dodawanie wartości o typach posiadających tę cechę. Wiele struktur implementuje cechę Clone, która umożliwia tworzenie ich kopii. Przykłady można by mnożyć.

Zobaczmy na prostym przykładzie jak to wygląda:

Powyższa cecha określa, że struktury ją implementujące będą obsługiwały funkcję get_some_int. Funkcja ta przyjmuje jako jedyny argument pożyczoną strukturę, a zwraca pewną 32-bitową liczbę bez znaku.

Taką cechę można zaimplementować dla różnych typów danych:

Jak widać, różne typy danych mogą implementować tę samą cechę na różne sposoby, adekwatne do danych reprezentowanych przez ten typ. Cechy mogą następnie być wykorzystywane do pisania funkcji, operujących na różnych typach danych, które implementują zadaną cechę, co może wyglądać np. tak:

W powyższym przykładzie funkcja add_one nie wie o swoim parametrze nic, poza tym, że implementuje on cechę SomeTrait. To jednak wystarcza, by było wiadomo, że można na nim wywołać funkcję get_some_int.

Nasze API będzie bazować właśnie na cechach. Główny program będzie wywoływał funkcje zdefiniowane we wtyczce, wiedząc jedynie, że te funkcje zwrócą mu dane implementujące określoną cechę. Będzie mógł wówczas wykonywać na tych danych pewne operacje, nie wiedząc nic o ich szczegółach. Np. komunikator mógłby poprosić wtyczkę obsługującą pewien protokół o obiekt reprezentujący kontakt, implementujący cechę która definiowałaby operacje takie jak wysyłanie wiadomości czy pobieranie danych o kontakcie. Nie wiedząc wtedy nic o szczegółach protokołu, będzie w stanie poprawnie obsługiwać komunikację za jego pomocą.

Implementacja

Przejdźmy zatem do przykładowej, banalnej implementacji.

Biblioteki w Ruście opakowywane są w tzw. "skrzynki" (ang. crates - nie wiem, czy istnieje jakieś oficjalne polskie tłumaczenie tego terminu). Skrzynki mogą być zależne od innych skrzynek i wykorzystywać ich funkcje. Podobnie aplikacje - mogą importować pewne skrzynki jako zależności. Stworzymy więc skrzynkę określającą interfejs wtyczki, która będzie importowana przez główną aplikację (aby program wiedział, jak korzystać z wtyczki) oraz skrzynkę wtyczki (aby wtyczka wiedziała, jakie funkcje udostępniać).

Kod naszej biblioteki definiującej przykładowy interfejs będzie równie skomplikowany, jak przykład powyżej:

Koniec. Jedyne, co będą potrafiły wtyczki do naszego programu, to zwracanie pewnych określonych przez siebie łańcuchów znaków.

Teraz prosta wtyczka:

Niewiele bardziej skomplikowana. Po pierwsze, deklarujemy, że będziemy korzystać ze skrzynki definiującej interfejs (nazwanej "plugin_interface"). Z tej skrzynki importujemy sobie cechę, określającą sposób komunikacji między naszą wtyczką a programem. Następnie definiujemy strukturę Whatever, dla której implementujemy tę cechę - przy wywołaniu funkcji get_some_string będzie ona zwracała po prostu "whatever".

Ostatni fragment jest czymś nowym. Otóż będziemy chcieli skompilować naszą wtyczkę do postaci dynamicznej biblioteki (pliku .dll w systemie Windows albo .so na Linuksie). Symbole udostępniane w takich bibliotekach to jedynie zmienne lub funkcje - ich format nie ma pojęcia o żadnych cechach ani niczym takim. Musimy jakoś to przezwyciężyć. Robimy zatem tak - biblioteka udostępni "gołą" funkcję, która zwróci tzw. "obiekt cechy" (ang. trait object) - wskaźnik do struktury danych zaalokowanej na stercie. Alokację na stercie w języku Rust uzyskuje się przez zastosowanie typu Box. W tym przypadku jednak nie określamy, jaka struktura będzie zwrócona, a jedynie, że będzie to coś implementujące cechę PluginTrait. Uzyskamy dzięki temu jednorodny interfejs, możliwy do wykorzystania przez dowolną wtyczkę.

Funkcja została także ozdobiona dwoma dodatkowymi informacjami: po pierwsze, że ma być eksportowanym symbolem (załatwia to słówko "extern"), a po drugie, że nazwa symbolu ma pozostać niezmieniona (to zapewnia atrybut #[no_mangle]) - domyślnie nazwy struktur w kodzie są mocno zmieniane przed ich wyeksportowaniem.

Pozostaje nam już tylko główny program:

Do załadowania wtyczki zastosujemy skrzynkę "libloading", umożliwiającą ładowanie dynamicznych bibliotek i wczytywanie z nich symboli. Rust posiadał stworzone do tego celu niestabilne API w bibliotece standardowej (która to niestabilność oznaczała konieczność korzystania z wersji "nightly" kompilatora), zostało ono jednak niedawno zastąpione przez tę właśnie skrzynkę. Korzysta się z niej zdecydowanie wygodniej, niż ze starego API.

Program wykonuje zatem następujące czynności: po pierwsze, próbuje załadować wtyczkę z pliku "libplugin.so" w bieżącym katalogu. Jeśli mu się to nie uda, rzuci błędem i zakończy wykonywanie. Następnie próbuje wczytać z załadowanej biblioteki symbol "object_factory", pod którym będzie się kryła zdefiniowana we wtyczce funkcja. Definiujemy tutaj typ danych symbolu jako funkcję zwracającą obiekt cechy PluginTrait - taki, jaki był we wtyczce. W tym właśnie miejscu program musi znać interfejs wtyczki, aby wiedzieć, co to właściwie jest PluginTrait.

Jeśli wczytanie funkcji udało się, możemy spróbować ją wywołać i wyświetlić wynik. Jeśli wszystko poszło dobrze, ujrzymy na ekranie tekst: "Result: whatever".

Parę słów na koniec

W tym przykładzie zademonstrowałem jedynie pobieranie obiektów z wtyczki przez program. Komunikacja w drugą stronę może odbywać się analogicznie - pewne funkcje wtyczki mogą jako argumenty przyjmować obiekty cech zdefiniowane w interfejsie, które będą do nich przekazywane z głównego programu. Inne funkcje mogą wtedy korzystać z danych udostępnionych przez program. Nie ma więc żadnych przeszkód, by ten schemat komunikacji rozszerzyć do przypadku dwustronnego.

To by było wobec tego chyba wszystko - wszelkie komentarze i uwagi będą mile widziane :) Pełen kod z tej notki dostępny jest na GitHubie: klik