原文:Introduction to Protocol Buffers on iOS
作者: Vincent Ngo
译者:kmyhy
对于大部分需要后台支持的 App 来说,转化和存储数据是非常重要的工作。和 web service 交互时,程序员常常需要发送或接收 JSON/XML 数据,创建数据结构并传递它们。
尽管已经有许多序列化/反序列化框架和 APIs,但这里有一个维护性的问题,比如版本管理以及当后台模型改变后需要对对象解析器进行修改。
如果你想创建全新的、健壮的后端和前端服务,请尝试 protocol buffers,它是一个和语言无关的序列化结构数据的方法,由 Google 所开发。许多时候,它都要比别的常用方法比如 JSON、XML 要更灵活高效。
它有一个重要的特点,只需要定义一次数据结构,编译器就能够生成多种语言的代码——包括 Swift! 它所产生的类文件能够毫不费力地对对象进行读和谐。
在本教程中,你将启动一个 Python 服务器,并集成一个已有 iOS APP 中的数据。然后,我们将介绍如何使用 protocol buffer,如何配置环境,如何用 protocol buffers 传递数据。
你是不是仍然觉得没有必要使用 protocol buffers?请看下面。
本教程假设你具备一定的 iOS 和 Swift 基本技能,了解基本的服务端编程,知道怎么使用终端。
同时,确认你使用的是最新版的 Xcode 8.2。
开始
RWCards 是一个 app,允许你查看自己的门票以及活动发言者清单。
首先下载开始项目,打开目录下面的 Starter。
请先熟悉一下这三部分内容:
客户端
在 Starter/RWCards 目录,打开 RWCards.xcworkspace 浏览一下主要的项目文件,包括:
- SpeakersListViewController.swift 负责列出发言者名单。这个控制器是一个模板,因为我们还没有创建模型对象。
- SpeakersViewModel.swift 充当 SpeakersListViewController 的数据源。它包含了发言者的列表。
- CardViewController.swift 负责显示一个出席者的图标以及他们的社交信息。
- RWService.swift 负责集成客户端和后台。我们将使用 Alamofire 来调用后台服务。
- Main.storyboard 包含了整个 App 用到的所有 scene。
这个项目通过 CocoaPods 集成了这两个框架:
- Swift Protobuf 允许你在你的 Xcode 项目中使用 protocol buffers。
- Almofire 是一个 HTTP 网络库,我们用它来访问服务器。
注意:本教程中我们将使用 Swift Protobuf 0.9.24 以及 Google 的 Protoc Compiler 3.1.0。它们都已经内置在开始项目中了,因此你不需要做什么。
如何使用 Protocol Buffers?
要使用 protocol buffers,首先必须定义一个 .proto 文件。在这个文件中,你需要定义一个消息类型,用于定义你的架构或数据结构。这是一个 .proto 文件的例子:
syntax = "proto3";
message Contact {
enum ContactType {
SPEAKER = 0;
ATTENDANT = 1;
VOLUNTEER = 2;
}
string first_name = 1;
string last_name = 2;
string twitter_name = 3;
string email = 4;
string github_link = 5;
ContactType type = 6;
string imageName = 7;
};
这里指定了一个 Contact 消息以及它的属性。
定义好 .proto 文件后,你只需要将文件传递给 protocol buffer 编译器,它将根据你选择的语言生成数据访问类(在 Swift 中是结构)。然后你就可以通过这个类/结构了。简单吧!
https://koenig-media.raywenderlich.com/uploads/2016/12/pb3.png’ width=’600’/>
编译器对消息进行翻译,将它的值类型映射为指定的语言,并生成相应的模型对象文件。后面会详细介绍如何定义消息。
在准备使用 Protocol buffers 之前,你首先应当明白为什么要在你的项目中使用它。
好处
JSON 和 XML 是程序员存储和数据转换的标准方式,但 protocol buffers 具有这些优势:
- 更快,更小:根据 Google 所说,protocol buffers 比起 XML 数据量小 3-10 倍,速度快 20-100 倍。请阅读 Damien Bod 的这篇文章,其中对不同主流数据格式的读写时间进行了比较。
- 类型安全:protocol buffers 和 Swift 一样是类型安全的。通过 protocol buffer 语言,你必须为每个属性指定属性。
- 自动反序列化:你不再需要编写千篇一律的解析代码。只需要修改你的 proto 文件,重新生成数据访问类。
- 面向分享:可以通过指定的语言,跨平台分享模型,这意味着进行跨平台时能够减少工作。
局限
Protocol buffers 有优点,也有缺点:
- 时间和工作量:将已有项目中切换到 protocol buffers 上时,可能会增加一些成本,因为这种转换需要成本。此外,它需要学习一种新语法。
- 人类不可读:XML 和 JSON 更加具有描述性,更容易阅读。Protocol buffer 的原始格式不是自描述的。如果没有 .proto 文件,你根本无法看懂数据。
- 它不是全能的:如果我们想使用样式表(比如 XSLT),那么最好用 XML。protocol buffers 并不适合于这种目的。
- 语言不支持:编译器可能不支持你想使用的那种语言。
它并不是什么情况下都能够使用,因此对于 protocol buffers 仍然有很多争论!
编译运行 App,看看它是什么样子。
https://koenig-media.raywenderlich.com/uploads/2016/12/pb2-6.gif’ width=’400’/>
不幸的是,你不能看到任何数据,因为数据源还没有准备好。你的任务是调用后台服务,用发言者列表和参与者角标来刷新 UI。首先,我们来看一下开始项目中提供的这两部分。
Protocol Buffer 的 Schema
回到 Finder,进入 Starter/ProtoSchema 目录,你会看到如下文件:
- contact.proto 通过 protocol buffer 语法描述了一个 contact 的结构。后面会详细介绍。
- protoScript.sh 是一个 bash 脚本,将通过 protocol buffer 编译器生成 contact.proto 所定义的 Swift 结构和 Python 类。
后台
在 folder Starter/Server 下面有如下文件:
RWServer.py 是一个 Python 服务器,基于 Flask 构建。它有两个 GET 请求:
- /currentUser 返回当前参会者的信息。
- /speakers 返回发言者的列表。
RWDict.py 包含了 RWServer 会读取的一个发言者的 dictionary。
然后是配置 protocol buffer 运行环境。在后面,你将安装 Google protocol buffer 编译器的运行环境、Swift Protobuf 插件、以及运行 Python 服务器所需要的 Flask。
安装环境
要使用 protocol buffers,我们需要安装一些工具和库。开始项目中已经包含了一个 protoInstallation.sh 脚本,它会为你完成所有的工作。而且更好的是,它还会在安装某个库之前检查其它库是否安装了。
这个脚本的执行会花一点时间,尤其是安装 Google 的 protocol buffer 库的时候。打开你的终端,切到开始项目目录,执行命令:
$ ./protoInstallation.sh
注意:执行这个脚本,可能需要你输入管理员密码。
当脚本执行完成,你可以再次执行它,并确认你看到如下输出:
https://koenig-media.raywenderlich.com/uploads/2016/12/Screen-Shot-2016-12-10-at-2.51.38-PM.png’ width=’550’/>
如果你看到上图的样子,说明脚本执行成功。如果脚本没有执行成功,可能你的管理员密码输错了。如果是这样,请重新运行脚本,对于已经执行成功的内容,它不会重复执行的。
这段脚本进行了如下工作:
- 安装 Flask,以便能够在本机运行 Python 服务器。
- 从 Starter/protobuf-3.1.0 目录编译 Google protocol buffer 编译器。
- 安装 Python 的 protocol buffer 模块,以便服务器能够调用 protobuf 库。
- 将 Swift Protobuf 插件拷贝到 /usr/local/bin。这使得 Protobuf 编译器能够生成 Swift 结构。
注意:要具体了解这个脚本是什么意思,请用文本编辑器打开 protoInstallation.sh 查看详细命令。这需要你具备一定的 bash 知识。
万事俱备,让我们开始使用 protocol buffers 吧!
定义一个 .proto 文件
.proto 文件用于定义 protocol buffer 消息,所谓消息描述了数据的结构。将它传递给 protocol buffer 编译器之后,就会生成数据访问器结构。
注意:在本教程中,我们将使用 proto3,这是最新的 protocol buffer 语言版本。要深入了解这种语法以及如何定义 proto3 文件,请看 Google 的官方指南。
用任意文本编辑器打开 ProtoSchema/contact.proto。这里,我们使用现成的 .proto 文件,它已经为我们定义好了 Contact 消息和 Speakers 消息:
syntax = "proto3";
message Contact { // 1
enum ContactType { // 2
SPEAKER = 0;
ATTENDANT = 1;
VOLUNTEER = 2;
}
string first_name = 1; //3
string last_name = 2;
string twitter_name = 3;
string email = 4;
string github_link = 5;
ContactType type = 6;
string imageName = 7;
};
message Speakers { // 4
repeated Contact contacts = 1;
};
这个定义包含了如下内容:
- Contact 模型描述了某个人的联系信息。在 App 中,这些信息会显示在这个人的徽章下面。
- 每个 Contact 都有一个类别,以便区分这个人是发言者还是听众。
- proto 文件中的 message 和 enum 定义中的每个字段都必须赋一个递增的、唯一的 tag 值。这个 tag 值会被 message 二进制格式中用作唯一标识,以便保持它们的顺序。更多关于 tag 值和字段管理的内容,请参考 google 文档中的 reserved fields。
- Speakers 模型是一个包含了 Contact 对象的集合。repeated 表示这是一个对象的数组。
生成 Swift 结构
当我们把 contact.proto 传递给 protoc 程序时,proto 文件就生成了 Swift 结构。这些结构将继承 ProtobufMessage 协议。protoc 会为每个 Swift 字段、初始化方法、以及序列化/反序列化方法提供属性。
注意:关于 Swfit protobuf API 的更多内容,请参考 Apple 的 Protobuf API 文档。
打开终端,切到 Starter/ProtoSchema 目录。在文本编辑器中打开 protoScript.sh :
#!/bin/bash
echo 'Running ProtoBuf Compiler to convert .proto schema to Swift'
protoc --swift_out=. contact.proto // 1
echo 'Running Protobuf Compiler to convert .proto schema to Python'
protoc -I=. --python_out=. ./contact.proto // 2
这个脚本会调用 protoc 两次——一次生成了 Swift 源文件,一次生成了 Python 文件。
回到终端,执行这个脚本:
$ ./protoScript.sh
你会看到:
Running ProtoBuf Compiler to convert .proto schema to Swift
protoc-gen-swift: Generating Swift for contact.proto
Running Protobuf Compiler to convert .proto schema to Python
这样我们就根据 contact.proto 文件生成了 Swift/Python 源文件。
在 ProtoSchema 目录,你会看到两个文件,一个 Swift 的,一个是 Python 的。注意每个新生成的文件都会以 .pb.swift 或 .ph.py 文件为后缀名。pb 后缀表示它是 protocol buffer 生成的。
https://koenig-media.raywenderlich.com/uploads/2016/12/generatedFiles-650x71.png’ width=’700’/>
将 contact.pb.swift 拖到 Xcode 的项目导航器中,并放到 Protocol Buffer Objects 文件夹下。注意勾选 “Copy items if needed” 选项。通过 Finder 或终端,将 contact_pb2.py 拷贝到 Starter/Server 文件夹。
大概看一下 contact.pb.swift 和 contact_pb2.py 的内容,看看 proto 的消息是如何映射成两种语言的结构的。
我们已经有了模型对象,是时候来使用他了!
运行本地服务器
示例项目包含了一个内置的 Python 服务器。这个服务器提供两个 GET 接口:一个返回听众的徽章信息,一个返回发言者列表。
本教程不涉及服务端代码。但是需要注意 contact_pb2.py 模型文件。另外如果你有兴趣,可以看一下 RWServer.py。当然对于本教程而言,这不是必须的。
要开启服务器,请打开终端,切换到 Starter/Server 目录。 执行命令:
$ python RWServer.py
你会看到:
https://koenig-media.raywenderlich.com/uploads/2016/12/startServer.png’ width=’600’/>
测试 GET 请求
用浏览器进行 HTTP 请求,就可以看见 protocol buffer 的原始数据格式。
访问 http://127.0.0.1:5000/currentUser ,你会看到:
https://koenig-media.raywenderlich.com/uploads/2016/12/Screen-Shot-2016-12-10-at-11.41.52-AM-650x61.png’ width=’600’/>
试一下另外一个接口, http://127.0.0.1:5000/speakers:
https://koenig-media.raywenderlich.com/uploads/2016/12/Screen-Shot-2016-12-19-at-10.14.00-PM-650x70.png’ width=’600’/>
注意:你可以保持本地服务器的运行,也可以停止服务器,然后在需要测试 RWCards App 时再启动它。
我们启动了一个简单服务器,这个服务器使用了 proto 文件中的消息作为我们的模型。太好了!
调用服务
让本地服务器启动并保持运行,让我们在 App 中调用它的服务。在 RWService.swift 中,将 RWService 类替换为:
class RWService {
static let shared = RWService() // 1
let url = "http://127.0.0.1:5000"
private init() { }
func getCurrentUser(_ completion: @escaping (Contact?) -> ()) { // 2
let path = "/currentUser"
Alamofire.request("\(url)\(path)").responseData { response in
if let data = response.result.value { // 3
let contact = try? Contact(protobuf: data) // 4
completion(contact)
}
completion(nil)
}
}
}
这个类负责和我们的Python 服务器进行通讯。我们用它实现了 currentUser 接口调用。代码说明如下:
- shared 是一个单实例,用于访问网络接口。
- getCurrentUser(_:) 方法用于请求 /currentUser 接口,以获取当前用户数据。这个用户在后台是以硬编码的形式定义的。
- 通过 if let 语句对响应值进行解包。
- data 对象中是 protocol buffer 的二进制形式。Contact 构造函数把它作为参数,然后对收到的消息进行解码。
将 protocol buffer 转换成对象非常简单,只需调用这个对象的构造函数并传入 data。不需要你手动解析数据。Swift Protobuf 库自动为你完成一切。
获得接口数据之后,我们要把它显示出来。
显示听众徽章
打开 CardViewController.swift,在 viewWillAppear(_:) 方法后面添加方法:
func fetchCurrentUser() { // 1
RWService.shared.getCurrentUser { contact in
if let contact = contact {
self.configure(contact)
}
}
}
func configure(_ contact: Contact) { // 2
self.attendeeNameLabel.attributedText = NSAttributedString.attributedString(for: contact.firstName, and: contact.lastName)
self.twitterLabel.text = contact.twitterName
self.emailLabel.text = contact.email
self.githubLabel.text = contact.githubLink
self.profileImageView.image = UIImage(named: contact.imageName)
}
这两个方法用于从服务器抓取数据并显示用户徽章。代码解释如下:
- fetchCurrentUser() 负责请求服务器,抓取当前用户信息,用 contact 对象刷新 CardViewController。
- configure(_:) 需要一个 Contact 参数,并对控制器中的 UI 控件进行赋值。
稍后我们再调用这两个方法,现在我们需要从 ContactType 枚举派生出一个可读的听众类型。
自定义 Protocol Buffer 类
我们需要用一个方法将枚举类型转换成字符串类型,这样发言者的徽章会显示成 SPEAKER 而不是 0。
这里有一个问题。因为每当修改消息之后都需要重新生成 .proto 文件,那么我们如何在模型中加入自己的方法呢?
Swift 的扩展能够解决这个问题。通过扩展,我们可以向某个类中添加方法,而不需要修改它用来的代码。
创建一个 contact+extension.swift 文件,将它加到 Protocol Buffer Objects 文件夹中。这个文件的内容编辑如下:
extension Contact {
func contactTypeToString() -> String {
switch type {
case .speaker:
return "SPEAKER"
case .attendant:
return "ATTENDEE"
case .volunteer:
return "VOLUNTEER"
default:
return "UNKNOWN"
}
}
}
contactTypeToString() 方法将 ContactType 类型转换为可显示的字符串。
打开 CardViewController.swift,在 configure(_:) 中加入:
self.attendeeTypeLabel.text = contact.contactTypeToString()
这句代码将 attendeeTypeLabel 的文本显示为由 contact 类型转换来的字符串表示。
最后,在 ViewWillAppear 方法的 applyBusinessCardAppearance() 一句后面加入:
if isCurrentUser {
fetchCurrentUser()
} else {
// TODO: handle speaker
}
isCurrentUser 当前被硬编码为 true,当我们需要支持发言者类型时我们再来修改它。当 isCurrentUser 为 true 时,调用 fetchCurrentUser(),这会抓取当前用户信息并显示到卡片中。
运行程序,查看听众的徽章。
https://koenig-media.raywenderlich.com/uploads/2016/12/Simulator-Screen-Shot-Dec-10-2016-11.00.10-AM.png’ width=’160’/>
显示发言者列表
在 My Badge 页完成之后,我们需要来完成 Spearkers 页面。
打开 RWService.swift,添加方法:
func getSpeakers(_ completion: @escaping (Speakers?) -> ()) { // 1
let path = "/speakers"
Alamofire.request("\(url)\(path)").responseData { response in
if let data = response.result.value { // 2
let speakers = try? Speakers(protobuf: data) // 3
completion(speakers)
}
}
completion(nil)
}
看起来很熟悉吧?这和 getCurrentUser(_:) 其实是一样的,不过它获取的是发言者数据。发言者是一个 Contact 对象数组,表示所有会议发言者。
打开 SpeakersViewModel.swift ,替换文件内容为:
class SpeakersViewModel {
var speakers: Speakers!
var selectedSpeaker: Contact?
init(speakers: Speakers) {
self.speakers = speakers
}
func numberOfRows() -> Int {
return speakers.contacts.count
}
func numberOfSections() -> Int {
return 1
}
func getSpeaker(for indexPath: IndexPath) -> Contact {
return speakers.contacts[indexPath.item]
}
func selectSpeaker(for indexPath: IndexPath) {
selectedSpeaker = getSpeaker(for: indexPath)
}
}
这个类用作 SpeakersListViewController 的数据源,用于显示一个会议发言者的列表。speakers 是一个 Contacts 数组,用 /speakers 接口返回的数据填充。这个数据源为表格的每一行提供一个 Contact 对象。
view model 准备好之后,我们就可以来配置单元格了。打开 SpeakerCell.swift 添加方法:
func configure(with contact: Contact) {
profileImageView.image = UIImage(named: contact.imageName)
nameLabel.attributedText = NSAttributedString.attributedString(for: contact.firstName, and: contact.lastName)
}
这个方法使用一个 Contact 参数,用它来对 cell 的 UIImage 和 UILabel 进行赋值。每个 cell 会包含一张发言者的图片,以及姓名。
然后,打开 SpeakersListViewController.swift,在 viewWillAppear(_:) 的父类方法调用之后添加:
RWService.shared.getSpeakers { [unowned self] speakers in
if let speakers = speakers {
self.speakersModel = SpeakersViewModel(speakers: speakers)
self.tableView.reloadData()
}
}
getSpeakers(_:) 方法负责请求并返回一个发言者列表。然后用返回的发言者列表,初始化 SpeakersViewModel 对象。然后用抓取到的数据刷新表格。
然后需要为表格中的每一行分配一个发言者以便显示。将 tableView(_:cellForRowAt:) 方法代码替换为:
let cell = tableView.dequeueReusableCell(withIdentifier: "SpeakerCell", for: indexPath) as! SpeakerCell
if let speaker = speakersModel?.getSpeaker(for: indexPath) {
cell.configure(with: speaker)
}
return cell
getSpeaker(for:) 方法返回指定 indexPath 所对应的 contact 对象。configure(with:) 方法是 SpeakCell 中定义的,作用是设置单元格的发言者图片和姓名。
当发言者列表中的 cell 被点击,我们要用 CardViewController 显示所选发言者信息。打开CardViewController.swift ,新增如下属性:
var speaker: Contact?
我们最终会将所选的发言者传递个这个属性。然后,我们需要显示发言者。将 // TODO: handle speaker 一行替换为:
if let speaker = speaker {
configure(speaker)
}
这里检查了 speaker 是否不为空,如果不为空,调用 configure() 方法,这个方法将用指定发言者信息刷新卡片。
回到 SpeakersListViewController.swift,传入选定的发言者。首先在 tableView(_:didSelectRowAt:) 方法中,在 performSegue(withIdentifier:sender:) 之前加入:
speakersModel?.selectSpeaker(for: indexPath)
这句会在 speakersModel 中记录用户所选择的发言者。
然后,在 prepare(for:sender:) 方法中,在 vc.isCurrentUser = false 一句后面添加:
vc.speaker = speakersModel?.selectedSpeaker
这句将 selectedSpeaker 传递给 CardViewController ,以便显示它。
看一下你的本地服务器是否仍然运行,然后编译运行 Xcode。现在,你会发现 App 已经能够显示用户徽章和发言者列表了。
https://koenig-media.raywenderlich.com/uploads/2016/12/pb4-1.gif” width=”300”/>
我们用一个 Python 服务器和一个 Swift 客户端打造了一个端到端应用。它们共用由同一个 proto 文件所生成的模型。如果你想修改模型,只需要运行一下编译器再次生成模型即可,这样你就可以同时在服务端和客户端使用了!
结束
你可以在这里下载最终完成项目。
在本教程中,我们学习了基本的 protocol buffers 用法,如何定义 .proto 文件、用编译器生成 Swift 代码。我们也学习了如何启动一个简单的 Flask 本地服务器,通过这个服务器我们创建了一个服务用于发送 protocol buffer 二进制到客户端,用 protocol buffers 解析这些数据是非常简单的!
关于 protocol buffers 还有很多内容,比如定义消息映射以及向后兼容。如果你对这些感兴趣,请参考Google 文档。
还有一件有意思的事情就是 protocol buffers 可以用在远程过程调用。请参考 GRPC。
有任何问题和建议,请在下面留言。