Основы Protocol Buffer: Dart

Базовое введение в работу с protocol buffers для программистов на Dart.

Данное руководство представляет собой базовое введение в работу с protocol buffers для программистов на Dart, используя версию языка protocol buffers proto3. На примере создания простого приложения показано, как:

  • Определять форматы сообщений в файле .proto.
  • Использовать компилятор protocol buffer.
  • Использовать Dart API protocol buffer для записи и чтения сообщений.

Это не исчерпывающее руководство по использованию protocol buffers в Dart. Для получения более подробной справочной информации смотрите Руководство по языку Protocol Buffer, Обзор языка Dart, Справочник по Dart API, Руководство по сгенерированному коду для Dart и Справочник по кодированию.

Предметная область

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

Как сериализовать и извлекать такие структурированные данные? Есть несколько способов решить эту проблему:

  • Можно придумать собственный способ кодирования элементов данных в единую строку — например, кодирование 4 целых чисел как "12:3:-23:67". Это простой и гибкий подход, хотя он требует написания специального кода для кодирования и парсинга, а парсинг несет небольшие затраты времени выполнения. Это лучше всего подходит для кодирования очень простых данных.
  • Сериализовать данные в XML. Этот подход может быть очень привлекательным, поскольку XML (в некоторой степени) читаем человеком, и для многих языков есть библиотеки привязок. Это может быть хорошим выбором, если вы хотите обмениваться данными с другими приложениями/проектами. Однако XML известен своей требовательностью к пространству, а его кодирование/декодирование может привести к огромным потерям производительности приложений. Кроме того, навигация по дереву XML DOM значительно сложнее, чем навигация по простым полям в классе.

Protocol buffers — это гибкое, эффективное, автоматизированное решение именно для этой проблемы. С protocol buffers вы пишете описание .proto структуры данных, которую хотите сохранить. На его основе компилятор protocol buffer создает класс, который реализует автоматическое кодирование и парсинг данных protocol buffer в эффективном бинарном формате. Сгенерированный класс предоставляет геттеры и сеттеры для полей, составляющих protocol buffer, и заботится о деталях чтения и записи protocol buffer как единого целого. Важно, что формат protocol buffer поддерживает идею расширения формата с течением времени таким образом, что код может читать данные, закодированные в старом формате.

Где найти код примера

Наш пример — это набор консольных приложений для управления файлом данных адресной книги, закодированным с помощью protocol buffers. Команда dart add_person.dart добавляет новую запись в файл данных. Команда dart list_people.dart разбирает файл данных и выводит данные в консоль.

Полный пример можно найти в директории examples репозитория GitHub.

Определение формата протокола

Чтобы создать приложение "адресная книга", нужно начать с файла .proto. Определения в файле .proto просты: вы добавляете сообщение для каждой структуры данных, которую хотите сериализовать, а затем указываете имя и тип для каждого поля в сообщении. В нашем примере файл .proto, определяющий сообщения, это addressbook.proto.

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

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

Далее идут определения сообщений. Сообщение — это просто агрегат, содержащий набор типизированных полей. Многие стандартные простые типы данных доступны в качестве типов полей, включая bool, int32, float, double и string. Вы также можете добавить дополнительную структуру в свои сообщения, используя другие типы сообщений в качестве типов полей.

message Person {
  string name = 1;
  int32 id = 2;  // Уникальный идентификационный номер для этого человека.
  string email = 3;

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;

  google.protobuf.Timestamp last_updated = 5;
}

// Наш файл адресной книги - это просто один из таких экземпляров.
message AddressBook {
  repeated Person people = 1;
}

В приведенном выше примере сообщение Person содержит сообщения PhoneNumber, а сообщение AddressBook содержит сообщения Person. Вы можете даже определять типы сообщений, вложенные в другие сообщения — как видно, тип PhoneNumber определен внутри Person. Вы также можете определять типы enum, если хотите, чтобы одно из ваших полей имело одно из предопределенного списка значений — здесь вы хотите указать, что номер телефона может быть одним из PHONE_TYPE_MOBILE, PHONE_TYPE_HOME или PHONE_TYPE_WORK.

Маркеры " = 1", " = 2" на каждом элементе идентифицируют уникальный "тег", который это поле использует в бинарном кодировании. Для номеров тегов 1-15 требуется на один байт меньше для кодирования, чем для старших номеров, поэтому в качестве оптимизации вы можете решить использовать эти теги для часто используемых или повторяющихся элементов, оставляя теги 16 и выше для реже используемых необязательных элементов. Каждый элемент в повторяющемся поле требует перекодировки номера тега, поэтому повторяющиеся поля являются особенно хорошими кандидатами для этой оптимизации.

Если значение поля не установлено, используется значение по умолчанию: ноль для числовых типов, пустая строка для строк, false для булевых значений. Для встроенных сообщений значением по умолчанию всегда является "экземпляр по умолчанию" или "прототип" сообщения, у которого не установлены никакие поля. Вызов аксессора для получения значения поля, которое не было явно установлено, всегда возвращает значение по умолчанию для этого поля.

Если поле repeated, поле может повторяться любое количество раз (включая ноль). Порядок повторяющихся значений сохраняется в protocol buffer. Consider repeated fields as dynamically sized arrays.

Вы найдете полное руководство по написанию файлов .proto — включая все возможные типы полей — в Руководстве по языку Protocol Buffer. Не ищите средства, подобные наследованию классов — protocol buffers этого не делают.

Компиляция ваших Protocol Buffers

Теперь, когда у вас есть .proto, следующее, что нужно сделать, — сгенерировать классы, которые понадобятся для чтения и записи сообщений AddressBook (и, следовательно, Person и PhoneNumber). Для этого нужно запустить компилятор protocol buffer protoc для вашего .proto:

  1. Если вы не установили компилятор, загрузите пакет и следуйте инструкциям в README.

  2. Установите плагин Dart Protocol Buffer, как описано в его README. Исполняемый файл bin/protoc-gen-dart должен быть в вашем PATH, чтобы protoc мог его найти.

  3. Теперь запустите компилятор, указав исходную директорию (где находится исходный код вашего приложения — используется текущая директория, если значение не указано), целевую директорию (куда вы хотите поместить сгенерированный код; часто совпадает с $SRC_DIR) и путь к вашему .proto. В этом случае вы должны вызвать:

    protoc -I=$SRC_DIR --dart_out=$DST_DIR $SRC_DIR/addressbook.proto
    

    Поскольку вам нужен код на Dart, вы используете опцию --dart_out — аналогичные опции предоставлены для других поддерживаемых языков.

Это генерирует addressbook.pb.dart в указанной вами целевой директории.

API Protocol Buffer

Генерация addressbook.pb.dart дает вам следующие полезные типы:

  • Класс AddressBook с геттером List<Person> get people.
  • Класс Person с методами доступа для name, id, email и phones.
  • Класс Person_PhoneNumber с методами доступа для number и type.
  • Класс Person_PhoneType со статическими полями для каждого значения в перечислении Person.PhoneType.

Вы можете подробнее прочитать о деталях того, что именно генерируется, в Руководстве по сгенерированному коду для Dart.

Запись сообщения

Теперь давайте попробуем использовать ваши классы protocol buffer. Первое, что вы хотите, чтобы ваше приложение "адресная книга" умело делать, — это записывать личные данные в файл адресной книги. Для этого нужно создать и заполнить экземпляры ваших классов protocol buffer, а затем записать их в выходной поток.

Вот программа, которая читает AddressBook из файла, добавляет одного нового Person в него на основе пользовательского ввода и записывает новый AddressBook обратно в файл. Части, которые напрямую вызывают или ссылаются на код, сгенерированный компилятором protocol, выделены.

import 'dart:io';

import 'dart_tutorial/addressbook.pb.dart';

// Эта функция заполняет сообщение Person на основе пользовательского ввода.
Person promptForAddress() {
  Person person = Person();

  print('Enter person ID: ');
  String input = stdin.readLineSync();
  person.id = int.parse(input);

  print('Enter name');
  person.name = stdin.readLineSync();

  print('Enter email address (blank for none) : ');
  String email = stdin.readLineSync();
  if (email.isNotEmpty) {
    person.email = email;
  }

  while (true) {
    print('Enter a phone number (or leave blank to finish): ');
    String number = stdin.readLineSync();
    if (number.isEmpty) break;

    Person_PhoneNumber phoneNumber = Person_PhoneNumber();

    phoneNumber.number = number;
    print('Is this a mobile, home, or work phone? ');

    String type = stdin.readLineSync();
    switch (type) {
      case 'mobile':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_MOBILE;
        break;
      case 'home':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_HOME;
        break;
      case 'work':
        phoneNumber.type = Person_PhoneType.PHONE_TYPE_WORK;
        break;
      default:
        print('Unknown phone type.  Using default.');
    }
    person.phones.add(phoneNumber);
  }

  return person;
}

// Читает всю адресную книгу из файла, добавляет одного человека на основе
// пользовательского ввода, затем записывает её обратно в тот же файл.
main(List arguments) {
  if (arguments.length != 1) {
    print('Usage: add_person ADDRESS_BOOK_FILE');
    exit(-1);
  }

  File file = File(arguments.first);
  AddressBook addressBook;
  if (!file.existsSync()) {
    print('File not found. Creating new file.');
    addressBook = AddressBook();
  } else {
    addressBook = AddressBook.fromBuffer(file.readAsBytesSync());
  }
  addressBook.people.add(promptForAddress());
  file.writeAsBytes(addressBook.writeToBuffer());
}

Чтение сообщения

Конечно, адресная книга была бы не очень полезна, если бы вы не могли извлечь из нее информацию! Этот пример читает файл, созданный приведенным выше примером, и печатает всю информацию в нем.

import 'dart:io';

import 'dart_tutorial/addressbook.pb.dart';
import 'dart_tutorial/addressbook.pbenum.dart';

// Итерируется по всем людям в AddressBook и печатает информацию о них.
void printAddressBook(AddressBook addressBook) {
  for (Person person in addressBook.people) {
    print('Person ID: ${ person.id}');
    print('  Name: ${ person.name}');
    if (person.hasEmail()) {
      print('  E-mail address:${ person.email}');
    }

    for (Person_PhoneNumber phoneNumber in person.phones) {
      switch (phoneNumber.type) {
        case Person_PhoneType.PHONE_TYPE_MOBILE:
          print('   Mobile phone #: ');
          break;
        case Person_PhoneType.PHONE_TYPE_HOME:
          print('   Home phone #: ');
          break;
        case Person_PhoneType.PHONE_TYPE_WORK:
          print('   Work phone #: ');
          break;
        default:
          print('   Unknown phone #: ');
          break;
      }
      print(phoneNumber.number);
    }
  }
}

// Читает всю адресную книгу из файла и печатает всю
// информацию внутри.
main(List arguments) {
  if (arguments.length != 1) {
    print('Usage: list_person ADDRESS_BOOK_FILE');
    exit(-1);
  }

  // Читает существующую адресную книгу.
  File file = File(arguments.first);
  AddressBook addressBook = AddressBook.fromBuffer(file.readAsBytesSync());
  printAddressBook(addressBook);
}

Расширение Protocol Buffer

Рано или поздно после выпуска кода, использующего ваш protocol buffer, вы, несомненно, захотите "улучшить" его определение. Если вы хотите, чтобы ваши новые буферы были обратно совместимыми, а ваши старые буферы были向前совместимыми — а вы почти certainly этого хотите — тогда есть некоторые правила, которым вы должны следовать. В новой версии protocol buffer:

  • вы не должны изменять номера тегов любых существующих полей.
  • вы можете удалять поля.
  • вы можете добавлять новые поля, но вы должны использовать новые номера тегов (т.е. номера тегов, которые никогда не использовались в этом protocol buffer, даже удаленными полями).

(Есть некоторые исключения из этих правил, но они редко используются.)

Если вы будете следовать этим правилам, старый код будет успешно читать новые сообщения и просто игнорировать любые новые поля. Для старого кода удаленные единичные поля будут просто иметь свое значение по умолчанию, а удаленные повторяющиеся поля будут пусты. Новый код также будет прозрачно читать старые сообщения.

Однако помните, что новые поля не будут присутствовать в старых сообщениях, поэтому вам нужно будет сделать что-то разумное со значением по умолчанию. Используется значение по умолчанию, зависящее от типа: для строк значением по умолчанию является пустая строка. Для булевых значений — false. Для числовых типов — ноль.