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
お問い合わせ・ご依頼はこちらから