游戏开发-协议设计-protobuf

本篇是游戏开发系列第二篇,如若你有兴趣,请持续关注,后期会持续更新。其余文章列表以下:java

游戏开发—协议设计python

游戏开发—协议-protobufgit

游戏开发-协议-protobuf原理详解github

WHAT

简介

咱们看官方文档是如此介绍的:golang

Protocol buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.ruby

Protocol buffers 是一个跨语言,跨平台以及支持可扩展的序列化结构数据的格式。bash

简单来讲,Protocol Buffers就是一种google定义的结构化数据格式,用于数据的序列化和反序列化。因为它直接对二进制源数据进行操做,因此它相对于xml来讲,足够的小,快以及简单,并且又与语言、平台无关,因此兼容性也有不错的表现。目前很适合作数据存储或 网络通信间的数据传输。网络

当前官方显示的已支持的开发语言多达10种,分别有:C++、Java、Python、Objective-C、C#、JavaNano、JavaScript、Ruby、Go、PHP,基本上主流的语言都已支持。固然也有非官方(好比Lua)的支持语言,具体也是增长一个解析lib,有特殊需求的能够参考官方文档本身编写。目前支持的语言以下(有source地址):jvm

Language Source
C++ (include C++ runtime and protoc) src
Java java
Python python
Objective-C objectivec
C# csharp
JavaNano javanano
JavaScript js
Ruby ruby
Go golang/protobuf
PHP


性能如何:

官方介绍的它性能足够强悍,具体有多好?咱们看下性能测试对比。socket

以上是基于Full Object Graph Serializers,包括建立对象,将对象序列化为内存中的字节序列,而后再反序列化的整个过程。图一是(序列化+反序列化)总共耗时,图二是压缩后的大小。咱们能够看出protocolBuffer不管是序列化速度,仍是数据大小,都有有明显优点。具体测试数据点此.

HOW

具体如何用,官方guide已经有很详细的介绍了,咱们基于官方demo对package进行一次分解,了解其序列化过程以及soruce结构,以便对整个机制有一个大概的了解(如下语言基于java)。

demo

此demo假定你已经拥有当前平台的compiler(.proto生成目标语言代码的编译器),如若没有,请参照官网编译C++ runtime and protoc,如若window平台,也能够点击此处下载一个,无需本身编译。

step1:引入maven

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.2.0</version>
</dependency>复制代码

step2:定义.proto文件

syntax = "proto3";
package msg;

option java_package = "com.example.msg";
option java_outer_classname = "LoginMsg";

message Login {
  string useranme = 1;
  int32  pw=2;
}复制代码

可支持的数据类型:

官网吧

step3:compiler生产代码

//--java_out是目标语言代码目录 紧跟着空格以后是.proto文件目录,生成多个可用-I
protoc --java_out=java resources/protoc/login.proto复制代码

最终生成的文件以及目录:

Reader&Writer

上述经过.proto定义生成的LoginMsg.java,已经整合了对LoginMsg的序列化和反序列化相关代码,咱们对login这个消息的reader和writer时只须要经过对该class进行操做便可。好比要把loginMsg写入到流里面发送出去,只须要对loginMsg进行赋值而后writer,对象就被序列化为二进制数据写出,或者接收端读取LoginMsg时,调用其ParserbyReader,就能够基于二进制流反序列化为LoginMsg对象。

Write:

public void write() throws Exception{
        //构建Login消息对象
        LoginMsg.Login.Builder builder = LoginMsg.Login.newBuilder();
        builder.setUseranme("wier");
        builder.setPwd(111);

        //序列化并写出到磁盘
        FileOutputStream output = new FileOutputStream("/Users/wier/login_msg");
        builder.build().writeTo(output);
        output.close();
    }复制代码

Read

public void read() throws Exception{
        FileInputStream inputStream = new FileInputStream("/Users/wier/login_msg");
        LoginMsg.Login login = LoginMsg.Login.parseFrom(inputStream);
        System.out.print("login.username:"+login.getUseranme());
        System.out.print("login.pwd:"+login.getPwd());

    }复制代码

咱们看到上述代码对消息的read和write都很简单,你只须要对上述的stream改造为为socket就能够基于tcp进行消息传输了。

Message类结构

咱们基于LoginMsg来看下整个消息对象主要包含的信息。

一个message类主要包含如下信息:

Login 消息结构对象的主体,主要存储数据,同时继承GeneratedMessageV3,内部封装对象的序列化和反序列化,writeTo序列化,paser反序列化。

LoginOrBuilder 用来链接Login和Builder,提供类型信息以及对外提供field get方法。

Builder 消息对象构建器,对外封装field set方法。

Descriptor 消息对象元数据的描述信息,通常用不到,若是你有动态解析的需求能够经过此来处理

Parser 解析器,为消息反序列号提供服务

咱们看下class的层次关系

MessageLite/Message接口是全部message的抽象接口,message能够基于Parser从字节流数据中构建对象,也能够经过Builder建立的对象序列化后写入字节流数据到IO管道,MessageLite和Message内部都定义了本身的Builder类,继承自MessageLiteOrBuilder以及MessageOrBuiler,并定义了MessageLite/Message和它们各自Builder类的共同接口。

调用时序

write

上面write的过程,咱们能够看到,数据的封装主要经过build来处理,GeneratedMessageV3封装了一些基础字段读取的操做,最终的字段的写入主要依靠CodedOutputStream来进行,CodedOutputStream封装的全部(定义类型)字段转二进制的方式,好比int,String 等,你只需基于定义字段传入便可。OutputStreamEncoder是CodedOutputStream是一个子类。

read

read的过程也是一个解包的过程,Parser主要来作解析管理,好比能够基于二进制数据或者基于IO来解析,或者一些扩展字段调用预注册的ExtensionRegister来本身定义解析。最终的字段读取调用CodedInputStream来读取,CodedInputStream和上面的CodedOutputStream同样,也是基于一些定义字段进行读取操做,将二进制数据转换为指定字段类型。消息的构造函数有基于CodedInputStream读取的,读取顺序基于tag来进行。具体每一个field的tag是作什么的后续讲解。

message二进制结构

经过上面的read和write过程,咱们能够看到每一个消息字段读取的时候,都会先调用一次readTag或者writeTag,那么这个tag是作什么的,咱们先看一个message的二进制组成结构。

一个二进制流,都是一队有序的byte数据组成,上述图中每一个field都是有一个tag和value组成,tag等于就是这个value信息的描述或者定义,告知解析器当前fields是什么类型字段,以及读取的顺序,有了这个信息,解析器就知道一个field在流中的开始位置和结束位置,如此一个field解码成功,而且与字段顺序无关。

tag的构成:

(fieldNumber << 3) | wireType;

为什么须要fieldNumber,一个是它能够告知解析器当前field在字节流中解析的顺序,另外也能够作到对协议的扩展,好比你在已经用到的协议消息中,须要增长一个字段或者更改一个字段,能够 fieldNumber+1,这样即使是一样一个消息,不管client是否更新协议(好比依然采用old message),依然不影响server端的解析。这样的机制,保证了即便该消息添加了新的字段,也不会影响旧的编/解码程序正常工做。

Descriptor

Descriptor 是消息对象的元数据描述信息,在compilerss生成消息对象class的时候,会为每一个message定义一个Descriptor静态字段、同时还会定义一个FieldAccessorTable静态字段用于使用反射读取/设置某个字段的值。

固然了这些在通常的序列化和反序列化的时候用不到,由于消息的解析顺序以及类型已经在生成的时候基于配置文件生成好了,无需再来解析标签含义。

若是你有动态解析的需求,好比:新增或者更新一个 Message 时候,不须要更代码,重启进程,基于接收到 数据和配置文件,自动建立具体的 Protobuf Message 对象,再作的反序列化。此时Descriptor对你有很大的帮助意义。咱们看下Descriptor下类层结构。

最后

extensions

在protocol2期间,还支持extensions字段定义,经过extend 用来解决消息复用的方式,目前在protocol3已经废弃了,采用Any来支持。

Unknown Fields

在protocol2期间,若是有没法解析的字段(如消息升级以后,client采用old message 传送),默认的解决方式以下:

default: 
        if (!parseUnknownField(input, unknownFields, extensionRegistry, tag)) {
          done = true;
        }复制代码
复制代码

现在protocol3已经对这一方案进行更新了,遇到没有定义的字段,直接skipField。

default: 
       if (!input.skipField(tag)) {
           done = true;
           }
       break;复制代码
本节只针对protocol buffer 的是什么,以及如何用进行了介绍,并无针对protocol为什么会有占用空间小,解析速度快以及兼容性等优势进行梳理,若是你对这部分有兴趣,请关注下一篇相关文字,我会尝试梳理一下关于why问题。


---------------------------------------------------end---------------------------------------------------

扫描关注更多,关注我的成长和技术学习,期待用本身的一点点改变,带给你一些启发及感悟。

相关文章
相关标签/搜索