Работаем с Google Protocol Buffer в РНР

7 ноября 2009

pb4php_logoПриветствуем наших читателей. На этот раз обойдусь без длительного вступления, а сразу перейду к делу, то есть, к теме сегодняшней статьи. В проекте, который я сейчас разрабатываю, возникла необходимость, а точнее, я понял, что архитектуру надо расширять, и решил заменить используемый сейчас протокол для обмена данными между частями приложения. Сейчас, на уровне внутренних сервисов, обмен происходит через передачу сериализированных массивов РНР поверх TCP сокетов. Так как по обе стороны находятся приложения на РНР, проблем не возникает, формат пакета данных также стандартный, поэтому особых сложностей нет. Разве что часто меня не удовлетворяет скорость обработки, а также то, что мы сильно завязаны на язык и платформу. Если придется стыковать с другой системой или же переписать что-либо, будут сложности - ведь сериализированный формат поймет лишь родной язык, а писать парсер мне не очень хочется. Первоначальный выбор был более чем оправданным - скорость разработки и отладки были приоритетными, сейчас есть немного времени и желания посмотреть на архитектуру с высока и другим взглядом.

Следует сказать, что данные передаются самые простые - строки (различной длины, на практике длинее килобайта или десятка почти нет, обычно это сотни байт), целые числа (в том числе и unix timestamp), некоторый набор констант, true/false флаги, только в одном случае передаются значения с плавающей точкой. В принципе, все сводится к трем типам данных - строка, целое число, число с плавающей точкой. Если хотеть, можно выделить еще поле кода команды, которое можно отнести к перечисляемому виду (количество команд ограничено и конечно, хоть и растет с ростом системы). В сериализированном виде такой пакет занимает достаточно много места, и хоть передаются данные по сокетах в пределах локальной машины, это все равно не выход - изначально система такая, что должна допускать динамическое расширение на несколько узлов кластера.

Навскидку приходит мысль использовать вместо PHP-массива сразу JSON, этим мы решим вопрос понимания протокола другими языками. Но здесь есть подводный камень в виде, насколько я понимаю, кодирования символов строковых, особенно кириллицы, которая преобразуется в UTF и представлена в виде \u3490, что значительно увеличивает объем передаваемых данных.

Так что я начал (в который раз, эх) исследовать различные форматы для взаимодействия между сервисами приложения, которые бы позволили обмениваться в будущем и между разными платформами, прозрачно передаваться по сети и быть, по возможности, максимально компактными. Очень приглянулся мне протокол Hessian (по тестам, его вторая реализация просто отличная), но слишком он замкнутый и очень мало документации. Поэтому в основном я рассматривал Facebook/Apache Thrift и Google Protocol Buffer.

Thrift теперь открытый и передан Apache Fundation, но его реализация достаточно сложна и запутана, вместе с минимумом документации и примеров (чтобы не сказать отсутствием), он отпал сразу, к тому же заставить работать вариант для РНР мне так и не удалось.

А вот Google Protocol Buffer (далее, для сокращения - PB) оказался очень даже приятным и интересным. Однако сложности начались сразу, так как моя задача была работать с ним  в РНР, а не Python или Java, как предлагают нам разработчики. Так как материалов по этой теме нет, решил описать свои шаги, на случай, если кому придется такое же делать. Сразу оговорюсь, что  сам протокол я не буду описывать, если вы не знакомы с ним - есть несколько хороших материалов.

И так, первое, что радует - для работы с PB нет необходимости устанавливать дополнительное ПО или компилировать расширение для РНР, а значит вы сможете провести эксперименты и на домашней машине и на виртуальном хостинге. Пока единственным средством работы в РНР является проект pb4php, который находится в ранеей стадии разработки (судя по номеру версии, 0.25, при этом разработка ведется одним человеком и начала больше года назад, хотя коммиты время от времени появляются, но активности очень небольшая). Его мы и будем использовать, но советую трезво оценить свои потребности - если вам достаточно основных возможностей формата и операций создания/чтения/записи/сериализации, то все хорошо, более продвинутые операции не поддерживаются.

Для примера представим самый простой вариант - у нас есть некоторый сервис, который получает новости, например, извлекает из RSS-лент, и есть сервер, который рассылает новости подписчикам. Мы хотим свести обмен данными между обоими сервисами к обмену данными через Protocol Buffer, при этом один из сервисов, или оба, у нас на РНР. Что и как делать?

Сначала необходимо согласовать формат сообщения, пусть это будет самый простой вариант:

  1. message News {
  2. required int32 id = 1;
  3. optional string source = 2;
  4. optional string dsign = 3;
  5. optional string news_msg = 4;
  6. optional int32 n_timestamp = 5;
  7. }

Мы описали самое простое сообщение, используя только базовые типы (пока никаких перечислений и констант, это вы уже на свое усмотрение используйте). Каждому полю сопоставили численный тег, необходимый для кодирования сообщения. Единственным обязательным полем, без которого наше сообщение не будет признано корректным, объявлен идентификатор, остальные поля могут отсутствовать. Конечно, в реальной ситуации формат более сложен, да и выбор типа поля нетривиален, но мы пока это опустим. Читая один из материалов о протоколе, я натолкнулся на слова разработчиков о том, что если вы часто меняете формат, желательно объявлять все поля опциональными, и только в финальной версии можно уже разграничивать, потому в примере у меня только одно обязательное поле. В случае отсутствия того или иного параметра он не будет включен в сообщение, а значит, экономия на размере пакета данных.

Указанный файл сохраняем в виде обычного текстового файла в формате *.proto. Далее, используя компилятор (загрузить можно здесь для различных платформ) мы компилируем сообщение в бинарный формат, по сути, шаблон для последующего использования.

Стандартный компилятор сразу генерирует враппер для сообщения, объекты и служебные методы для поддерживаемых языков - Java, C++, Python. Однако мы же работает с РНР, которого пока нет в списке.

В пакете pb4php есть скрипт (лежит в директории example/protoc.php), который делает то же самое (хоть и коряво, честно) для РНР - загружает указанный proto файл и генерирует структуру РНР-класса для работы с сообщением (да-да, код на РНР). Обратите внимание, этот компилятор работает с текстовым описанием сообщения, тем самым файлом *.proto, что вы создали выше.

Однако я предпочел сгенерировать класс-обертку для сообщения вручную, в частности, обнаружил ошибку в самих файлах примеров. Компилятор неверно генерирует указатель на тип данных строка - константы PBMessage::WIRED_STRING нет в исходниках, хотя она присутствует во всех примерах, вам придется заменить ее на PBMessage::WIRED_LENGTH_DELIMITED.

Класс-обертка наследуется от общего класса PBMessage, добавляя описания конкретных полей вашего сообщения, устанавливает геттеры/сеттеры для полей. Судя по коду, внутри они хранятся как обычный ассоциативный массив, лишь при сериализации кодируясь в двоичный формат PB.

Наш класс очень простой и будет хранится в файле pb_news_interface.php:

  1. class News extends PBMessage
  2. {
  3. var $wired_type = PBMessage::WIRED_LENGTH_DELIMITED;
  4.  
  5. public function __construct($reader=null)
  6. {
  7. parent::__construct($reader);
  8.  
  9. $this->fields["1"] = "PBInt";
  10. $this->values["1"] = 0;
  11.  
  12. $this->fields["2"] = "PBString";
  13. $this->values["2"] = '';
  14.  
  15. $this->fields["3"] = "PBString";
  16. $this->values["3"] = '';
  17.  
  18. $this->fields["4"] = "PBString";
  19. $this->values["4"] = '';
  20.  
  21. $this->fields["5"] = "PBInt";
  22. $this->values["5"] = 0;
  23. }
  24.  
  25. function id()
  26. {
  27. return $this->_get_value('1');
  28. }
  29.  
  30. function set_id($value)
  31. {
  32. return $this->_set_value('1', $value);
  33. }
  34.  
  35. function source()
  36. {
  37. return $this->_get_value('2');
  38. }
  39.  
  40. function set_source($value)
  41. {
  42. return $this->_set_value('2', $value);
  43. }
  44.  
  45. function dsign()
  46. {
  47. return $this->_get_value('3');
  48. }
  49.  
  50. function set_dsign($value)
  51. {
  52. return $this->_set_value('3', $value);
  53. }
  54.  
  55. function news_msg()
  56. {
  57. return $this->_get_value('4');
  58. }
  59.  
  60. function set_news_msg($value)
  61. {
  62. return $this->_set_value('4', $value);
  63. }
  64.  
  65. function n_timestamp()
  66. {
  67. return $this->_get_value('5');
  68. }
  69.  
  70. function set_n_timestamp($value)
  71. {
  72. return $this->_set_value('5', $value);
  73. }
  74.  
  75. }

В конструкторе мы описываем формат данных, используя предопределенные имена для типов данных, pb4php отображает их на классы основных типами данных протокола - PBInt, PBBool (наследуется от PBInt), PBSignedInt, PBEnum (перечисления), PBString и PBBytes. Такая обертка нужна, так как далеко не все типы данных протокола можно напрямую отобразить на встроенные типы данных языка, а другие просто дублируются, например sint32/int32 в C++ отображаются на один и тот же тип int32 (хотя вообще вопрос типов данных непростой и таблица отображения из документации не дает исчерпывающего ответа). pb4php реализует лишь несколько базовых типов, так что вам придется выбирать самые общие типы для описания своего формата.

Кстати, сам код обертки далеко не оптимален, его вполне можно заменить более простым и коротким, используя магические методы __get/__set, видимо код писался еще и для совместимости с устаревшими версиями РНР и ООП-возможности там используются далеко не в полную силу.

Ок, давайте дальше. Для использования протокола в программе необходимо подключить два служебных файла - основной класс для работы с сообщениями ( /message/pb_message.php ) и наш сгенерированный класс-обертку для сообщений (pb_news_interface.php). После этого мы работает далее как с обычным РНР-классом.

Создаем экземпляр класса:

  1. $new_news = new News();

Заполняем поля:

  1. $new_news->set_id(1); //установим идентификатор в 1
  2.  
  3. $new_news->set_n_timestamp(time()); //добавим метку времени
  4.  
  5. $new_news->set_news_msg('Тестовая новость'); //тело сообщения
  6.  
  7. // остальные поля не заполняем, они будут иницилизированы дефолтными значениями

Для дальнейшей работы нам необходимо получить сериализированное значение нашего сообщения, например, для дальнейшей передачи через сеть. Для этого есть встроенный метод SerializeToString, который кодирует сообщение в строку (в HEX).

Кстати, автор позаботился даже о встроенном методе передачи объектов по сети - просто вызовите метод Send и передайте ему URL и экземпляр сообщения, далее через cURL будет сделан POST-запрос с параметром message, который содержит сериализированное PB-сообщение. Хотя в реальности я бы не стал использовать встроенный механизм передачи данных, скорее просто для тестирования возможностей.

Еще есть интересная константа MODUS, которая отвечает за формат хранения - в двоичном виде или строковом. Двоичный эффективней для передачи по сети и экономит трафик, строковый удобный для тестирования и чтения разработчиком.

Отправляем сообщение встроенным методом:

  1. $new_news->Send('http://domain.com/pb-service/server.php');
  2.  
  3. //получение сериализированной строки
  4. $res = $new_news->SerializeToString(); //странный параметр передается еще, не разобрался к чему он

Проводя исследование, я взял типовый набор данных, которые гоняются в моем приложении, и попробовал сравнить вариант с родной сериализацией РНР и Protocol Buffer в варианте pb4php. Выигрыш на размере данных получился около 30% (127 байт против 186 в обычном виде), хотя это никак не претендует на серьезное исследование, просто было интересно сравнить эффективность на моем реальном наборе данных.

Полученную строку можно записать в файл или передать любым другим способом, для восстановления первоначальной формы объекта достаточно создать экземпляр и вызвать метод ParseFromString.

  1. $tmp = $new_news->SerializeToString(); //сериализируем
  2.  
  3. $test = new News(); // создаем экземпляр сообщения
  4.  
  5. $test->ParseFromString($test); //восстанавливаем сообщение
  6.  
  7. //проверим
  8.  
  9. echo 'ID: ' . $test->get_id(); // вернет: ID: 1

Кстати, о некоторых расширенных возможностях PB, которые поддерживаются в библиотеке - посмотрите в директории /examples/nested_mess/, там есть пример работы как раз с преобразованием RSS в Protocol Buffer с использованием сообщений, которые содержат внутри себя, кроме полей с обычными типами, так же и вложенные классы.

И так, в итоге. Используя возможности pb4php мы можем:

  • По исходной спецификации Protocol Buffer сообщения генерировать PHP-класс обертку (хоть и неоптимальной структуры);
  • Работать в РНР-скрипте с классами сообщений как с обычными РНР-классами, заполнять поля данными;
  • Сериализировать сообщение в шестнадцатеричную строку для последующей записи в файл или передачи по сети (включая встроенный метод HTTP-POST). Итоговое сообщение обычно занимает на 10 - 30% меньше , чем обычный сериализированный массив или JSON (хотя не уверен, с JSON не тестировал еще, но подозреваю, что это так);
  • Восстанавливать сообщение в РНР-класс из его сериализированного вида.
  • В сообщения допускается использование базовых типов данных, а также вложенных сообщений.

Конечно, сериализация отнимает ресурсы и она обычно всегда дольше обычной нативной, хотя на простых сообщениях (где несколько полей) это почти никак не заметно. Основной выиграш происходит из-за сокращения объема данных (особенно критично при передаче по сети и большом потоке сообщений), а также легкой возможности строить сервисы, которые не зависят от языка реализации и особенностей платформы. Единственное, что не радует, это практически замороженное развитие  проекта (имеется ввиду pb4php, а не сам Protocol Buffer) и его уникальность - для многих языков есть по несколько реализаций. Интересно было бы иметь также вариант с С-расширением для РНР, это бы значительно ускорило операции сериализации/восстановления, а также хотелось бы поддержки от основных фреймворков, вроде Zend Framework.

Но даже в таком виде проектом можно пользоваться и если перед вами стоит задача быстро научиться обрабатывать сообщения в формате Google Protocol Buffer на РНР - используйте pb4php!

  1. Nyckolay
    9 ноября 2009 в 13:44 | #1

    спасибо за статью.
    вот только злой робот поменял все знаки больше на >

  2. Nyckolay
    9 ноября 2009 в 13:46 | #2

    @Nyckolay
    ну т.е. на & gt;

  3. 25 ноября 2009 в 03:47 | #3

    Интересная статья, спасибо! Удалось сэкономить ощутимые ресурсы? Не совсем понятно, действительно ли эта экономия внутресетевого трафика дает почуствовать разницу в скорости для клиента?

  4. 9 декабря 2009 в 12:08 | #4

    @Den Golotyuk
    Для клиента напрямую нет — но так как у меня система построена так, что внутри сервера, прежде, чем клиенту уйдут данные, сервисы много общаются (и есть несколько каналов общения, где уже более-менее значимые объемы данных ходят), то чем быстрее и больше они смогут общаться, тем больше выигрыш. Пока, конечно, это больше эксперимент — реально сейчас рассматриваю различные варианты, которые есть в ZF Incubator (компонент сериализации, там есть в том числе и бинарные варианты, и кросс-языковые).

  5. stacmv
    11 октября 2013 в 20:53 | #5

    Как-то сложно все, даже для 2009 года.
    Использование JSON совместно с urlencode/urldecode (для обхода uXXXX проблемы) кажется предпочтительнее. Хотя могу и ошибаться, упуская что-то из вида.

Комментирование отключено.
Developers.org.ua