协议缓冲区基础:Dart

面向 Dart 程序员的 protocol buffers 使用入门基础。

本教程为 Dart 程序员提供了 protocol buffers 的基本入门介绍,使用的是 proto3 版本的 protocol buffers 语言。通过创建一个简单的示例应用程序,本教程将向您展示如何:

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

这不是一份在 Dart 中使用 protocol buffers 的综合指南。如需更详细的参考信息,请参阅协议缓冲区语言指南Dart 语言导览Dart API 参考Dart 生成代码指南以及编码参考

问题领域

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

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

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

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

在哪里找到示例代码

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

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

定义你的协议格式

要创建您的地址簿应用,您需要从一个 .proto 文件开始。.proto 文件中的定义很简单:为您想要序列化的每个数据结构添加一个消息(message),然后为消息中的每个字段指定名称和类型。在我们的示例中,定义消息的 .proto 文件是 addressbook.proto

.proto 文件以包声明开头,这有助于防止不同项目之间的命名冲突。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

接下来,是您的消息定义。消息只是一个包含一组类型化字段的聚合体。许多标准的简单数据类型都可以作为字段类型,包括 boolint32floatdoublestring。您还可以通过使用其他消息类型作为字段类型,为您的消息添加更多结构。

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_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK 中的一种。

每个元素上的 " = 1"、" = 2" 标记标识了该字段在二进制编码中使用的唯一“标签”(tag)。标签号 1-15 比较大的数字少用一个字节来编码,因此作为一种优化,您可以决定将这些标签用于常用或重复的元素,而将标签 16 及以上的数字留给不常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合进行这种优化。

如果某个字段未设置值,则会使用默认值:数字类型为零,字符串为空字符串,布尔值为 false。对于嵌入的消息,默认值始终是该消息的“默认实例”或“原型”,其所有字段都未设置。调用访问器获取未显式设置的字段值时,总是返回该字段的默认值。

如果一个字段是 repeated,该字段可以重复任意次数(包括零次)。重复值的顺序将在协议缓冲区中保留。可以把重复字段看作是动态大小的数组。

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

编译你的 Protocol Buffers

现在您有了一个 .proto 文件,接下来需要做的就是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,您需要在您的 .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<Person> get people getter。
  • 一个 Person 类,带有用于 nameidemailphones 的访问器方法。
  • 一个 Person_PhoneNumber 类,带有用于 numbertype 的访问器方法。
  • 一个 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 中:

  • 您*绝不能*更改任何现有字段的标签号。
  • 您*可以*删除字段。
  • 您*可以*添加新字段,但必须使用新的标签号(即,在此协议缓冲区中从未使用过的标签号,即使是被删除的字段用过的也不行)。

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

如果您遵守这些规则,旧代码将能够愉快地读取新消息,并简单地忽略任何新字段。对于旧代码来说,被删除的单数(singular)字段将只有其默认值,而被删除的重复(repeated)字段将为空。新代码也将透明地读取旧消息。

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