Google Protocol Buffers – Wydajna i elastyczna alternatywa dla XML
XML to potężne i przenośne rozwiązanie, aczkolwiek czasami wydaje się być nieco… przerośnięte. Jeśli szukasz prostszej, bardziej wydajnej, a zarazem elastycznej alternatywy, to koniecznie przeczytaj poniższy artykuł. Biblioteka Google Protocol Buffers jest prawdopodobnie właśnie tym czego szukasz!
Źródło: sdjournal.org
Autor: RAFAŁ KOCISZ
Niniejszy artykuł przedstawia bibliotekę Google Protocol Buffers, która oferuje niezależny od platformy i języka programowania, rozszerzalny mechanizm służący do serializacji danych o regularnej strukturze. Jak piszą autorzy biblioteki, rozwiązanie to można postrzegać jako prostszą, lżejszą i bardziej efektywną alternatywę dla formatu XML.
W kolejnych punktach artykułu opiszę koncepcję Google Protocol Buffers, a następnie pokażę jak można skonfigurować i zastosować w praktyce tę bibliotekę. Google Protocol Buffers dostępna jest w trzech językach: C++, Java i Python, aczkolwiek większość przykładów zaprezentowanych w niniejszym artykule opierać się będzie na C++.
Co mi da Google Protocol Buffers?
Na początek spróbujmy odnaleźć odpowiedź na fundamentalne pytanie: do czego może mi się przydać biblioteka Google Protocol Buffers?
Rozważmy prosty przykład. Wyobraź sobie, że piszesz prostą aplikację do zarządzania listą zadań (ang. task manager). Fundamentalną strukturą danych w tej aplikacji jest zadanie (ang. task), składające się z takich pól jak: tytuł, kategoria, opcjonalny opis oraz opcjonalna data, która wskazuje termin określający do kiedy zadanie musi być wykonane. Jednym z fundamentalnych zadań Twojej aplikacji będzie serializacja i deserializacja listy zadań. Możesz to zrealizować na kilka sposobów:
- Bezpośredni zapis zawartości pól struktur do pliku (w postaci binarnej). Ten sposób, na pierwszy rzut oka wydaje się być najprostszy, okazuje się jednak fatalnym rozwiązaniem pod względem przenośności oraz rozszerzalności. Pojawiają się zależne od platformy problemy związane z mapowaniem danych w pamięci (ang. memory layout) czy z kolejnością zapisu bajtów (ang. endianness), których rozwiązanie nie jest wcale trywialne. Występują również kłopoty wynikające z ewolucji struktur danych, na których pracuje program: wszystkie zmiany trzeba w takim przypadku wykonywać manualnie, na poziomie kodu źródłowego; bardzo kłopotliwe staje się również utrzymywanie zgodności z poprzednimi wersjami programu.
- Próba zakodowania danych programu w postaci tekstowej. W przypadku przetwarzania niewielkich ilości danych o prostej strukturze podejście to sprawdza się całkiem nieźle. Wymaga ono stworzenia i utrzymywania dedykowanego parsera (można w tym celu użyć np. wyrażeń regularnych). Niestety, w przypadku przetwarzania dużych ilości danych o skomplikowanej strukturze, nakład pracy na stworzenie parsera staje się uciążliwy. Dodatkowo rodzi się problem związany z optymalizacją wydajności procesu przetwarzania danych, przez co utrzymywanie parsera staje się jeszcze bardziej złożone.
- Zapis danych w formacie XML. W teorii: panaceum na wszystkie opisane wyżej problemy (pełna przenośność, duża elastyczność, ogólnodostępne parsery itd.). Niestety, w praktyce często okazuje się, że XML obarczony jest dużym, a co gorsza – niepotrzebnym narzutem. Parsery XML to zazwyczaj olbrzymie kombajny, które ważą niemało, zaś ich wydajność pozostawia sporo do życzenia; dokumenty zapisywane w formacie XML zawierają bardzo dużo metadanych co skutkuje nadmiernym zużyciem pamięci dyskowej. Kłopotliwe jest również mapowanie struktury drzewa DOM na strukturę obiektów w pamięci programu.
Wykorzystanie Google Protocol Buffers. W tym przypadku uzyskasz te same zalety co w przypadku stosowania formatu XML, unikając jednocześnie powiązanych z nim wad. W jaki sposób twórcy tej biblioteki rozwiązali tę łamigłówkę, przekonasz się czytając kolejny punkt.
Google Protocol Buffers: z czym to się je?
Rozwiązanie opracowane w ramach Google Protocol Buffers wygląda następująco: na początek należy przygotować specjalny plik (.proto), zawierający definicję struktury danych, którą chcemy serializować/deserializować. W kolejnym kroku należy wykorzystać generator kodu (dostępny w ramach biblioteki), który na podstawie opisu zawartego w pliku .proto stworzy implementację klasy zawierającej operacje serializacji i deserializacji, a także metody dostępu do składowych (ang. getters/setters). Jedyne co musi zrobić programista, to stworzyć w kodzie instancję takiej klasy i wywołać odpowiednie metody. Proste, prawda?
Jak to wygląda w praktyce?
Rozważmy jak w praktyce można wykorzystać Google Protocol Buffers. Jak wspomniałem na wstępie, biblioteka ta jest dostępna w trzech językach: C++, Java i Python. Na potrzeby niniejszego artykułu zastosujemy implementację w języku C++.
Na początek bibliotekę należy pobrać z Internetu. Jest ona ogólnie dostępna w serwisie Google Code (patrz: Ramka W Sieci). W trakcie pisania niniejszego artykułu Google Protocol Buffers dostępna była w wersji 2.3.0.
Biblioteka oferowana jest zarówno w postaci źródłowej oraz binarnej. Ta druga wersja będzie dla nas nieprzydatna, więc pobieramy pierwszą. Po rozpakowaniu archiwum z biblioteką (umówmy się, że katalog do którego rozpakowana została biblioteka nazywać będziemy %PROTOBUF_HOME%), należy ją zbudować. Ja w tym celu skorzystałem z pakietu Microsoft Visual Studio 2008. W podkatalogu %PROTOBUF_HOME%/vsprojects znajduje się plik solucji oraz plik projektów kompatybilne z Visual Studio, a także pliki readme.txt, który opisuje dokładnie proces budowania biblioteki. Oprócz tego w archiwum biblioteki znajdują się pliki konfiguracyjne umożliwiające jej zbudowanie pod inne, popularne systemy.
Gdy biblioteka zostanie pomyślnie zbudowana można wziąć się do pracy. Spróbujemy przy jej pomocy stworzyć silnik do zapisu i odczytu danych w naszym hipotetycznym programie TaskManager. Zaczynamy od definicji struktury danych. Definicję tę umieścimy w pliku task_list.proto. Zawartość tego pliku przedstawiona jest na Listingu 1.
Przyjrzymy się po kolei zawartym tam elementom. Na samym początku w oczy rzuca się definicja pakietu. W przypadku generowania kodu w języku C++ pakiet ten będzie odwzorowany na przestrzeń nazw, dzięki czemu łatwo da się uniknąć kolizji pomiędzy nazwami typów. Dalej mamy definicję tzw. wiadomości (ang. message). Wiadomość w nomenklaturze biblioteki Google Protocol Buffers to struktura, bądź rekord. W języku C++ każda wiadomość będzie reprezentowana jako klasa. Wiadomości posiadają dane składowe. W naszym przykładzie na początek definiujemy wiadomość o nazwie Task (zadanie). Zgodnie z wcześniejszymi założeniami, każde zadanie posiada nazwę (ang. name), kategorię (ang. category), opcjonalny opis (ang. description) oraz opcjonalną datę (ang. date). Na Listingu 1 można zaobserwować jak powyższe założenia przekładają się na kształt definicji wiadomości Task. Rozważmy przykładową definicję pola: required string name = 1;
Na początku mamy słowo kluczowe określające, czy dane pole jest wymagane czy nie (w tym drugim przypadku należałoby użyć słowa kluczowego optional), tudzież czy może występować wielokrotnie (słowo kluczowe: repeated). Dalej znajdujemy deklarację typu pola, przy czym typ ten może być zarówno typem podstawowym jak i typem złożonym, zdefiniowanym przez użytkownika. Wreszcie mamy nazwę pola (w naszym przypadku: name). Każda definicja pola ma przypisany tzw. tag. Tag w kontekście Google Protocol Buffers to unikalna liczna, która służy do identyfikacji pól w plikach zawierających wiadomości zapisane w postaci binarnej. Rola tagów opisana będzie w dalszej części artykułu.
Jak widać na przykładowym pliku .proto, w definicjach wiadomości można umieszczać również enumeracje (u nas enumeracja została wykorzystana w celu wyliczenia kolejnych miesięcy w roku). Wiadomości mogą być również zagnieżdżone (tak właśnie jest w przypadku definicji wiadomości Date). Jak widać w zaprezentowanym przykładzie plik .proto może zawierać dowolną liczbę definicji wiadomości.
Generowanie kodu
W efekcie budowania biblioteki Google Protocol Buffers uzyskujemy szereg komponentów. Jednym z nich jest kompilator (ang. Protocol Buffers Compiler): protoc.exe. Korzystając z tego narzędzia możemy wygenerować kod na podstawie naszego przykładowego pliku .proto. Posłużymy się w tym celu następującą komendą (zakładam, że plik protoc.exe jest dostępny w miejscu w którym znajduje się plik task_list.proto):
protoc.exe –cpp_out=. task_list.proto
W efekcie otrzymamy dwa pliki: task_ list.pb.h oraz task_list.pb.cc, które zawierają implementację klas reprezentujących wiadomości zdefiniowane w task_list.proto. Gdybyśmy zamienili powyższą komendę na: protoc.exe –python_out=. task_ list.proto lub na: protoc.exe –java_out=. task_list.proto uzyskalibyśmy kod do obsługi naszych wiadomości wygenerowany odpowiednio w Pythonie bądź w Javie.
Teraz czas zastosować wygenerowany kod w rzeczywistym programie.
Wiadomość w akcji!
Aby skorzystać z wygenerowanego kodu potrzeba niewiele. Plik .cc trzeba oczywiście skompilować i zlinkować z bibliotekami Google Protocol Buffers (uzyskanymi w procesie budowania). Kompilator musi też widzieć pliki nagłówkowe Google Protocol Buffers. Kiedy to wszystko uda się pomyślnie skonfigurować, to wreszcie można napisać i skompilować program przedstawiony na Listingu 2.
Jak widać, generator kodu dołączony w ramach Google Protocol Buffers odrobił za nas przysłowiową czarną robotę i stworzył definicje klas odpowiadające strukturom danych występującym w zadanym mu pliku .proto.
W naszym przykładzie tworzymy na początek listę zadań (TaskList), którą następnie wypełniamy zawartością. Warto zwrócić uwagę, że operacja dodania nowego zadania (Task) do listy odbywa się w dość specyficzny sposób:
taskmanager::Task* newTask1 =
taskList.add_
task();
Obiekt reprezentujący zadanie jest tworzony i dodawany w metodzie add_task(), po czym dostajemy wskaźnik ustawiony na ten obiekt. Dzięki temu nie musimy się kłopotać własnoręcznym tworzeniem obiektu, tudzież związanym z tym niebezpieczeństwem wycieku pamięci.
Otrzymany obiekt, w którym pola zainicjowane są wartościami domyślnymi, możemy dowolnie modyfikować, co też czynimy. Warto zwrócić uwagę na dedykowane funkcje do modyfikacji pól (set_name(), set_description()). W przypadku złożonej składowej (date), dostęp do niej uzyskujemy poprzez metodę mutable_date(), która zwraca niestałą referencję do obiektu.
W przedstawionym przykładzie dodajemy dwa zadania, po czym zapisujemy listę na dysk za pomocą wywołania:
taskList.SerializeToOstream(&ofs);
Obiekt ofs reprezentuje strumień zapisu do pliku. Warto w tym miejscu zauważyć, że biblioteka Google Protocol Buffers jest bardzo elastyczna w kontekście mechanizmów serializacji: wiadomość może być zapisana na różne sposoby, np. do tablicy czy do napisu; w całości lub fragmentami.
Po uruchomieniu programu w jego katalogu roboczym otrzymamy wynikowy, binarny plik tasklist.db. Oczytanie tego pliku jest równie łatwe jak jego zapis. Czynność ta zrealizowana jest na Listingu 3.
Widać w tym przypadku dobitnie, jak prosto można deserializować, a następnie odwoływać się do składników naszej listy zadań. Gdybyśmy chcieli zrealizować to zadanie przy pomocy języka XML, to po pierwsze: musielibyśmy sami stworzyć klasy reprezentujące struktury danych, zaś po drugie: napisać kod odpowiedzialny za fabrykowanie obiektów tych klas na podstawie zawartości pliku XML. Pomijam już fakt, że sama składnica danych w postaci pliku XML zajmowała by o wiele więcej miejsca zaś operacje jej zapisu i odczytu byłyby o rząd wielkości wolniejsze w stosunku do binarnego formatu wspieranego przez bibliotekę Google Protocol Buffers.
Sztuczki i haczyki…
Przykłady pokazane na Listingach 2 i 3 są (celowo) bardzo proste. Google Protocol Buffers oferuje programiście cały szereg praktycznych udogodnień, które usprawniają pracę z tą biblioteką. W tym punkcie przedstawię kilka z nich. Więcej informacji na ten temat należy szukać w dokumentacji biblioteki (patrz: Ramka W Sieci)
Bardzo ważne jest to, aby pliki nagłówkowe oraz skompilowane biblioteki Google Protocol Buffers miały tę samą wersję. Twórcy udostępnili w tym celu specjalne makro:
GOOGLE_PROTOBUF_VERIFY_VERSION;
którego umieszczenie na początku funkcji main() sprawdzi ten warunek.
Oprócz metod dostępu, każda klasa reprezentująca wiadomość posiada zestaw przydatnych, standardowych metod:
- bool IsInitialized() const;: sprawdza czy wszystkie wymagane (required) pola w wiadomości zostały ustawione.
- string DebugString() const;: zwraca napis prezentujący zawartość wiadomości w czytelnej postaci. Tej metody moglibyśmy użyć w programie pokazanym na Listingu 3 w celu wypisania zawartości poszczególnych zadań; wystarczyłoby napisać: std:: cout << taskList.DebugString() << std::endl;. Wynikowy napis (dla naszej przykładowej listy zadań) przedstawiony jest na Listingu 4.
- void CopyFrom(const Task& from);: nadpisuje zawartość pól danej wiadomości wartościami pobranymi z from.
- void Clear();: przywraca wiadomość do stanu początkowego (pola są puste, bądź zainicjowane wartościami do-myślnymi).
Należy również pamiętać, iż klasy reprezentujące wiadomości są, mimo wszystko, tylko prostymi kontenerami, służącymi do przechowywania pól. W sytuacji kiedy pojawia się potrzeba rozszerzenia możliwości tych klas (np. gdybyśmy chcieli dodać do naszej klasy reprezentującej zadanie metody implementujące jakąś bardziej zaawansowaną logikę) to zalecanym podejściem jest użycie agregacji, tj. opakowanie wiadomości inną klasą. Absolutnie nie powinno się stosować w tym celu dziedziczenia, gdyż 1) jest to niezgodne z dobrym stylem obiektowego projektowania klas; 2) psuje zachowanie wewnętrznych mechanizmów biblioteki Google Protocol Buffers.
Zmiany, zmiany…
… to jedyny pewnik w przedsięwzięciach informatycznych – tak mawiają kierownicy projektów z branży IT. I mają rację. Prędzej czy później zmiany dotkną również Twoich programów oraz danych, na których one operują. Jak w takiej sytuacji radzie sobie biblioteka Google Protocol Buffers? Okazuje się, że całkiem nieźle…
Kluczem do zarządzania zmianami w przypadku omawianej biblioteki są tagi, o których wspominałem na początku artykułu. Przypomnę, że tagi te identyfikują jednoznacznie pola w wiadomościach i na przestrzeni czasu absolutnie nie wolno ich modyfikować. Generalnie, Google Protocol Buffers narzuca kilka prostych zasad, które pozwolą zarówno Twoim programom jak i danym przetrwać trudną próbę zmian. Zasady te są następujące – na przestrzeni czasu:
- nie wolno zmieniać numerów tagów w żadnym z istniejących pól,
- nie wolno dodawać lub usuwać żadnych pól wymaganych (required),
- wolno usuwać pola oznaczone jako opcjonalne (optional) lub powtarzalne (repeated),
- wolno dodawać nowe opcjonalne lub powtarzalne pola, pod warunkiem, że użyjemy nowego numeru taga.
Voila! Przy tych założeniach niestraszne nam zmiany. Rozważmy to na konkretnym przykładzie. Na początek zachowajmy sobie plik wykonywalny odczytujący zawartość listy zadań. Następnie wygenerujmy nowy zestaw klas reprezentujących tę listę, z dodanym nowym, opcjonalnym polem opisującym priorytet zadania:
optional uint32 priority = 5;
Następnie korzystając ze zmodyfikowanej wersji programu z Listingu 2 zapiszmy nowy plik tasklist.db, zawierający informację
o priorytetach. Modyfikacje polegać będą na dodaniu dwóch linijek ustawiających wartości pola priority:
newTask1->set_priority(0);
oraz:
newTask2->set_priority(5);
Teraz wielka chwila: uruchamiamy zachowany wcześniej plik wykonywalny, podsuwając mu nowe dane. To działa! Nasz stary program poprawnie wczytał listę zadań. Nowe pole zostało po prostu pominięte. Co więcej; nasza lista będzie równie bezproblemowo obsłużona przez program napisany w Javie czy Pythonie. To jest dopiero przenośność! Gdybyśmy korzystali z prostej serializacji obiektów (tak jak opisałem to na początku artykułu) to opisany wyżej scenariusz zakończył by się najprawdopodobniej spektakularną katastrofą…
Kończąc niniejszy punkt warto wspomnieć, iż Google Protocol Buffers oferuje szereg mechanizmów wspomagających proces zarządzania tagami pól. Więcej szczegółów na ten temat można znaleźć w dokumentacji biblioteki (patrz: Ramka W Sieci).
Podsumowanie: jeszcze więcej możliwości!
Na tym etapie można by powiedzieć, c’est ça! (fr. to jest to!). Mamy wydajną i elastyczną alternatywę dla formatu XML! Okazuje się, że biblioteka Google Protocol Buffers niesie ze sobą cały szereg innych możliwości. Jak sugerują twórcy tej biblioteki, za jej pomocą można zaimplementować w języku C++ coś na kształt mechanizmu refleksji (ang. reflection) z języka Java. Mechanizm ten pozwala w trakcie wykonywania programu iterować po składowych obiektu i modyfikować ich zawartość. Obecność takiego mechanizmu daje programistom języka C++ bardzo szerokie pole do popisu. Korzystając z Google Protocol Buffers należy pamiętać również jaka organizacja jest odpowiedzialna za jej stworzenie i co się z tym wiąże. Komponenty biblioteczne wyprodukowane przez Google mają opinię najbardziej intensywnie przetestowanych na świecie. Warto podkreślić również, iż biblioteka Google Protocol Buffers jest bardzo intensywnie wykorzystywana w infrastrukturze Google: stanowi ona podstawowy komponent do przetwarzania danych w tej infrastrukturze. To ewidentnie świadczy o jakości tego rozwiązania…
Oczywiście istnieją nadal dziedziny, w których XML razem z całą gamą towarzyszących mu technologii satelitarnych (XML Schema, XSLT, XQuery, XPAth, itd.) pozostaje niezwyciężony. Jednakże, zanim sięgniesz po to narzędzie – sprawdź zaprezentowaną tu alternatywę!
Resumując, serdecznie zachęcam Cię drogi Czytelniku do zapoznania się i – co ważniejsze – do praktycznego zastosowania Google Protocol Buffers w Twoich rozwiązaniach. Najprawdopodobniej nie pożałujesz tego!
W Sieci
- http://code.google.com/intl/pl/apis/protocolbuffers/ – strona domowa biblioteki Google Protocol Buffers.
- http://code.google.com/intl/pl/apis/protocolbuffers/docs/overview.html – sekcja dokumentów na stronie domowej Google Protocol Buffers; znajdziesz tu szereg cennych informacji o tym rozwiązaniu.
- http://code.google.com/intl/pl/apis/protocolbuffers/docs/tutorials.html – sekcja samouczków związanych z biblioteką Google Protocol Buffers.
- http://code.google.com/intl/pl/apis/protocolbuffers/docs/proto.html – tu znajdziesz szczegółowy opis języka opisu struktur danych, wykorzystywanego w plikach .proto.
Licencja
Biblioteka Google Protocol Buffers jest udostępniana na licencji BSD. Licencja ta przewiduje możliwość wykorzystywania biblioteki bez uiszczania żadnych opłat, tak w otwartych jak i w komercyjnych projektach.
RAFAŁ KOCISZ
Pracuje na stanowisku Dyrektora Technicznego w firmie Gamelion, wchodzącej w skład Grupy BLStream. Rafał specjalizuje się w technologiach związanych z produkcją oprogramowania na platformy mobilne, ze szczególnym naciskiem na tworzenie gier. Grupa BLStream powstała, by efektywniej wykorzystywać potencjał dwóch szybko rozwijających się producentów oprogramowania – BLStream i Gamelion. Firmy wchodzące w skład grupy specjalizują się w wytwarzaniu oprogramowania dla klientów korporacyjnych, w rozwiązaniach mobilnych oraz produkcji i testowaniu gier. Kontakt z autorem: rafal.kocisz@gamelion.com
Listing 1. Zawartość pliku task_list.proto
package taskmanager; message Task { required string name = 1; required string category = 2; optional string description = 3; enum Month { JAN = 0; FEB = 1; MAR = 2; APR = 3; MAY = 4; JUN = 5; JUL = 6; AUG = 7; SEP = 8; OCT = 9; NOV = 10; DEC = 11; } message Date { required uint32 year = 1; required Month month = 2; optional uint32 day = 3; } optional Date date = 4; } message TaskList { repeated Task task = 1; }
Listing 2. Wiadomość w akcji: zapisywanie #include#include #include "task_list.pb.h" intmain() { taskmanager::TaskList taskList; taskmanager::Task* newTask1 = taskList.add_task(); newTask1->set_name("Buy birthday gift for Monisia"); newTask1->set_category("family"); newTask1->mutable_date()->set_year(2010U); newTask1->mutable_date()->set_month(taskmanager::Task_Month_MAR); newTask1->mutable_date()->set_day(10U); taskmanager::Task* newTask2 = taskList.add_task(); newTask2->set_name("Send monthly project report to Bob"); newTask2->set_category("job"); newTask2->mutable_date()->set_year(2010U); newTask2->mutable_date()->set_month(taskmanager::Task_Month_FEB); newTask2->mutable_date()->set_day(28U); std::ofstream ofs("tasklist.db"); if(taskList.SerializeToOstream(&ofs)) { std::cout << "task list serialization succeed!" << std::endl; return0; } else{ std::cout << "task list serialization failed!" << std::endl; return1; } }
Listing 3. Wiadomość w akcji: odczyt #include#include #include "task_list.pb.h" intmain() { taskmanager::TaskList taskList; std::ifstream ifs("tasklist.db"); if(taskList.ParseFromIstream(&ifs)) { std::cout << "task list deserialization succeed!" << std::endl; } else{ std::cout << "task list deserialization failed!" << std::endl; return1; } intntasks = taskList.task_size(); for(inti=0; i Listing 4. Przykładowy napis zwrócony z metody DebugString() task { name: "Buy birthday gift for Monisia" category: "family" date { year: 2010 month: MAR day: 10 } } task { name: "Send monthly project report to Bob" category: "job" date { year: 2010 month: FEB day: 28 } }









Zostaw odpowiedź