Protocol Buffer 基础知识:Dart

Dart 程序员使用 Protocol Buffer 的基本介绍。

本教程为 Dart 程序员提供了使用 Protocol Buffer 的基本介绍,采用的是 Protocol Buffer 语言的proto3版本。通过创建一个简单的示例应用,它向你展示了如何

  • 在 `.proto` 文件中定义消息格式。
  • 使用 Protocol Buffer 编译器。
  • 使用 Dart Protocol Buffer API 编写和读取消息。

这不是在 Dart 中使用 Protocol Buffer 的全面指南。有关更详细的参考信息,请参阅Protocol Buffer 语言指南Dart 语言之旅, Dart API 参考Dart 生成代码指南编码参考

问题领域

我们要使用的示例是一个非常简单的“地址簿”应用程序,它可以将人们的联系方式读写到文件中。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话。

你如何序列化和检索这样的结构化数据?有几种方法可以解决这个问题

  • 你可以发明一种临时的方法将数据项编码成一个字符串——比如将 4 个整数编码为“12:3:-23:67”。这是一种简单灵活的方法,尽管它确实需要编写一次性的编码和解析代码,并且解析会带来少量的运行时开销。这种方法最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法非常有吸引力,因为 XML (某种程度上) 是人类可读的,并且有很多语言的绑定库。如果你想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML 以占用大量空间而闻名,并且对其进行编码/解码可能会对应用程序带来巨大的性能开销。此外,遍历 XML DOM 树比正常情况下遍历类中的简单字段要复杂得多。

Protocol buffers 是灵活、高效、自动化的解决方案,正是为了解决这个问题。使用 Protocol buffers,你编写一个 `.proto` 文件来描述你想要存储的数据结构。然后,Protocol Buffer 编译器会创建一个类,该类使用高效的二进制格式实现 Protocol Buffer 数据的自动编码和解析。生成的类为构成 Protocol Buffer 的字段提供了 getters 和 setters,并负责处理作为一个单元读取和写入 Protocol Buffer 的细节。重要的是,Protocol Buffer 格式支持随着时间推移扩展格式的思想,这样代码仍然可以读取使用旧格式编码的数据。

在哪里找到示例代码

我们的示例是一套命令行应用程序,用于管理使用 Protocol buffers 编码的地址簿数据文件。命令 `dart add_person.dart` 将一个新条目添加到数据文件中。命令 `dart list_people.dart` 解析数据文件并将数据打印到控制台。

你可以在 GitHub 仓库的examples 目录中找到完整的示例。

定义你的协议格式

要创建你的地址簿应用程序,你需要从一个 `.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;  // Unique ID number for this person.
  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;
}

// Our address book file is just one of these.
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 中保留。可以将重复字段视为动态大小的数组。

你可以在Protocol Buffer 语言指南中找到编写 `.proto` 文件的完整指南——包括所有可能的字段类型。不过,不要寻找类似类继承的功能——Protocol buffers 不支持这些。

编译你的 Protocol Buffers

现在你有了 `.proto` 文件,接下来需要做的是生成读取和写入 `AddressBook`(以及 `Person` 和 `PhoneNumber`)消息所需的类。为此,你需要对你的 `.proto` 文件运行 Protocol Buffer 编译器 `protoc`

  1. 如果你还没有安装编译器,请下载软件包并按照 README 中的说明进行操作。

  2. 按照其 README 中的说明安装 Dart Protocol Buffer 插件。可执行文件 `bin/protoc-gen-dart` 必须在你的 `PATH` 中,以便 Protocol Buffer `protoc` 能够找到它。

  3. 现在运行编译器,指定源目录(你的应用程序源代码所在的位置——如果你不提供值,则使用当前目录)、目标目录(你希望生成代码存放的位置;通常与 `$SRC_DIR` 相同)以及你的 `.proto` 文件路径。在这种情况下,你会调用

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

    因为你需要 Dart 代码,所以使用 `--dart_out` 选项——其他支持的语言也提供了类似的选项。

这会在你指定的目标目录中生成 `addressbook.pb.dart`。

Protocol Buffer API

生成 `addressbook.pb.dart` 会为你提供以下有用的类型

  • 一个 `AddressBook` 类,带有一个 `List get people` getter。
  • 一个 `Person` 类,带有 `name`、`id`、`email` 和 `phones` 的访问方法。
  • 一个 `Person_PhoneNumber` 类,带有 `number` 和 `type` 的访问方法。
  • 一个 `Person_PhoneType` 类,带有 `Person.PhoneType` 枚举中每个值的静态字段。

你可以在Dart 生成代码指南中阅读有关生成内容的详细信息。

编写消息

现在让我们尝试使用你的 Protocol Buffer 类。你的地址簿应用程序首先需要能够将个人详细信息写入你的地址簿文件。为此,你需要创建并填充你的 Protocol Buffer 类实例,然后将它们写入输出流。

这是一个程序,它从文件读取 `AddressBook`,根据用户输入向其中添加一个新的 `Person`,然后将新的 `AddressBook` 再次写回文件。直接调用或引用由协议编译器生成的代码的部分已突出显示。

import 'dart:io';

import 'dart_tutorial/addressbook.pb.dart';

// This function fills in a Person message based on user input.
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;
}

// Reads the entire address book from a file, adds one person based
// on user input, then writes it back out to the same file.
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';

// Iterates though all people in the AddressBook and prints info about them.
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);
    }
  }
}

// Reads the entire address book from a file and prints all
// the information inside.
main(List arguments) {
  if (arguments.length != 1) {
    print('Usage: list_person ADDRESS_BOOK_FILE');
    exit(-1);
  }

  // Read the existing address book.
  File file = new File(arguments.first);
 AddressBook addressBook = new AddressBook.fromBuffer(file.readAsBytesSync());
  printAddressBook(addressBook);
}

扩展 Protocol Buffer

在使用你的 Protocol Buffer 的代码发布后,迟早你会想“改进” Protocol Buffer 的定义。如果你希望你的新缓冲区能够向后兼容,并且你的旧缓冲区能够向前兼容——你几乎肯定希望如此——那么你需要遵循一些规则。在 Protocol Buffer 的新版本中

  • 不得更改任何现有字段的标签号。
  • 你可以删除字段。
  • 你可以添加新字段,但必须使用全新的标签号(即在此 Protocol Buffer 中从未使用的标签号,即使是被删除的字段也未曾使用过的)。

(这些规则有一些例外情况,但很少使用。)

如果你遵循这些规则,旧代码将愉快地读取新消息并简单地忽略任何新字段。对于旧代码,被删除的 singular 字段将简单地具有其默认值,而被删除的 repeated 字段将为空。新代码也将透明地读取旧消息。

但是,请记住新字段不会出现在旧消息中,因此你需要对默认值进行合理的处理。使用类型特定的默认值:对于字符串,默认值为空字符串。对于布尔值,默认值为 false。对于数值类型,默认值为零。