MAGAZINE
ルーターマガジン
Schema定義なしにProtocol Buffersをparseしてみる
						こんにちは、アルバイトの上野です。今回はProtocol Buffersでシリアライズされたバイナリをデコードし、デコードされたファイルをRubyでparseする方法について紹介したいと思います。
Protocol Buffers とは
データをバイナリ形式にシリアライズ化するためのフォーマットで、IDLによってデータ構造を定義します。サーバー間を通信するgRPCなどに使われています。
IDLの定義
まず、以下のようにaddress.protoを定義します. ここでProtocol Buffersで通信するデータ構造を定義しています.syntax = "proto3";
package addresspb;
message Person{
    string name = 1;
    int32 id = 2;
    string email = 3;
    double weight = 5;
    bool isAlive = 8;
    enum PhoneType{
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
    }
    message PhoneNumber{
        string number = 1;
        PhoneType type = 2;
    }
    repeated PhoneNumber phones = 11;
}
message AddressBook{
    repeated Person people = 1;
}
最初の行の syntax = "proto3";でproto2とproto3の文法のうちproto3を使うことを宣言しています. この文を省略するとproto2を使うように解釈されます.
package addresspb の package は、プロジェクト間での名前衝突を防ぐためのパッケージ名です.
フィールド定義の文末に=1や=8などの数字を書いて、各フィールドにフィールド番号を定義する必要があります。シリアライズされたデータでは、nameなどのフィールド名ではなく、フィールド番号でフィールドを識別するためです。
.protoファイルのコンパイル
以下のコマンドでProtocol Buffersの定義をもとにしたRubyコードが自動生成されます。
protoc --ruby_out=. address.proto
実行後、カレントディレクトリにaddress_pb.rbが生成されます。
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: address.proto
require 'google/protobuf'
Google::Protobuf::DescriptorPool.generated_pool.build do
  add_message "addresspb.Person" do
    optional :name, :string, 1
    optional :id, :int32, 2
    optional :email, :string, 3
    optional :weight, :double, 5
    optional :isAlive, :bool, 8
    repeated :phones, :message, 11, "addresspb.Person.PhoneNumber"
  end
  add_message "addresspb.Person.PhoneNumber" do
    optional :number, :string, 1
    optional :type, :enum, 2, "addresspb.Person.PhoneType"
  end
  add_enum "addresspb.Person.PhoneType" do
    value :MOBILE, 0
    value :HOME, 1
    value :WORK, 2
  end
  add_message "addresspb.AddressBook" do
    repeated :people, :message, 1, "addresspb.Person"
  end
end
module Addresspb
  Person = Google::Protobuf::DescriptorPool.generated_pool.lookup("addresspb.Person").msgclass
  Person::PhoneNumber = Google::Protobuf::DescriptorPool.generated_pool.lookup("addresspb.Person.PhoneNumber").msgclass
  Person::PhoneType = Google::Protobuf::DescriptorPool.generated_pool.lookup("addresspb.Person.PhoneType").enummodule
  AddressBook = Google::Protobuf::DescriptorPool.generated_pool.lookup("addresspb.AddressBook").msgclass
end
シリアライズを行う
生成されたコードを使ってデータをシリアライズします。
ここではシリアライズしたデータをファイル serialized_data に書き出しています。
require 'google/protobuf'
require './address_pb'
home_phonenumber = Addresspb::Person::PhoneNumber.new(number: '08042170000', type: :HOME)
mobile_phonenumber = Addresspb::Person::PhoneNumber.new(number: '090344444', type: :MOBILE)
person = Addresspb::Person.new(name: "John", id: 34, email: "example.com", isAlive: true, weight: 80.34, phones: [home_phonenumber, mobile_phonenumber])
person2 = Addresspb::Person.new(name: "Bob", id: 939, email: "example2.com", isAlive: false, weight: 60.3, phones: [home_phonenumber])
address = Addresspb::AddressBook.new(people: [person, person2])
serialized_data = Addresspb::AddressBook.encode(address)
File.open("serialized_data", "wb") do |f|
  f.write(serialized_data)
end
バイナリデータのparse
書き出した serialized_data ファイルの中身をみてみると、データがバイナリとしてシリアライズ化されているのがわかります。
data = File.binread("serialized_data")
p data
# "\n>\n\x04John\x10\"\x1A\vexample.com)\xF6(\\\x8F\xC2\x15T@@\x01Z\x0F
# \n\v08042170000\x10\x01Z\v\n\t090344444\n0\n\x03Bob\x10\xAB\a\x1A\fex
# ample2.com)fffff&N@Z\x0F\n\v08042170000\x10\x01"
https://developers.google.com/protocol-buffers/docs/encoding
通常のProtocol Buffersの用途では、このバイナリファイルを直接見るという機会はほぼありません。Schema定義があるので、読み込むだけで各言語の変数としてロードされます。
ただし、エンコーディングの仕様は上記サイトにのっているため、それに基づいて、バイナリデータをparseして元のデータを調べることができます。
以下がparseのためのコード binary_parser.rb です。
require 'stringio'
def to_hex(data)
  data.unpack("H*")[0].to_i(16)
end
def get_varint(data, start)
  pos = 0
  num = to_hex(data[start]) & 0x7F
  while to_hex(data[start + pos]) & 0x80 == 0x80
    pos += 1
    if start + pos >= data.size
      return [nil, nil]
    end
    num += ((to_hex(data[start + pos]) & 0x7F) <> 6)
  exponent = ((to_hex(data[start + 7]) & 0x7F) <> 4)
  fraction = to_hex(data[start + 6]) & 0x0F
  idx = 5
  while idx >= 0
    fraction = (fraction << 8) + to_hex(data[start + idx])
    idx -= 1
  end
  if exponent == 0 && fraction == 0
    0
  else
    sign * (1 + fraction / (2.0 ** 52)) * (1 <> 6)
  exponent = ((to_hex(data[start + 3]) & 0x7F) <> 7)
  fraction = to_hex(data[start + 2]) & 0x7F
  idx = 1
  while idx >= 0
    fraction = (fraction << 8) + to_hex(data[start + idx])
    idx -= 1
  end
  if exponent == 0 && fraction == 0
    0
  else
    sign * (1 + fraction / (2.0 ** 23)) * (1 <> 3, wire_type, pos]
end
def add_data(data_hash, key, value)
  if data_hash[key].nil?
    data_hash[key] = value
  elsif data_hash[key].class == Array
    data_hash[key].push value
  else
    data_hash[key] = [data_hash[key], value]
  end
end
def unescape(string)
  $stdout = StringIO.new
  puts string
  unescaped_string = $stdout.string
  $stdout = STDOUT
  unescaped_string.chop
end
def parse(data)
  parsed_data = {}
  start = 0
  while start < data.size
    field_number, wire_type, pos = wiretypes(data, start)
    return false if pos.nil?
    start += pos
    if wire_type == 0 # Varint
      num, pos = get_varint(data, start)
      return false if num.nil?
      add_data(parsed_data, field_number, num)
      start += pos
    elsif wire_type == 1 # 64-bit
      return false if start + 8 > data.size
      num = get_64bit(data, start)
      add_data(parsed_data, field_number, num)
      start += 8
    elsif wire_type == 2 # Length-delimited
      length, pos = get_varint(data, start)
      if length.nil? || (start + length + pos > data.size)
        return false
      end
      res_data = parse(data.slice(start + pos, length))
      if res_data
        add_data(parsed_data, field_number, res_data)
      else 
        data_string = data.slice(start + pos, length)
        unescaped_string = unescape(data_string)
        add_data(parsed_data, field_number, unescaped_string)
      end
      start += (pos + length)
    elsif wire_type == 5 # 32-bit
      return false if start + 4 > data.size
      num = get_32bit(data, start)
      add_data(parsed_data, field_number, num)
      start += 4
    else
      false
    end
  end
  if start == data.size
    parsed_data
  else
    false
  end
end
binary_file = ARGV[0]
data = File.binread(binary_file)
parsed_hash = parse(data)
pp parsed_hash
実行結果です。
$ bundle exec ruby binary_parser.rb serialized_data
{1=>
  [{1=>"John",
    2=>34,
    3=>"example.com",
    5=>80.34,
    8=>1,
    11=>[{1=>"08042170000", 2=>1}, {1=>"090344444"}]},
   {1=>"Bob",
    2=>939,
    3=>"example2.com",
    5=>60.3,
    11=>{1=>"08042170000", 2=>1}}]}
同じフィールド番号に対応するデータが一つの場合は、ハッシュで一対一に対応させ、対応するデータが複数ある場合はリストとして値を扱っています
nameなどのフィールド名や型名の情報は抜け落ちていますが、protoファイルで定義したフィールド番号とデータが対応しているのがわかります。
ただ、フィールド番号8のisAliveはtrueの場合、8=>1としてきちんと表示されますが、falseの場合フィールド番号8の項目自体が消えてしまっています。
CONTACT
お問い合わせ・ご依頼はこちらから