MAGAZINE

ルーターマガジン

クローリング/スクレイピング

Schema定義なしにProtocol Buffersをparseしてみる

2021.01.28
Pocket

こんにちは、アルバイトの上野です。今回は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の項目自体が消えてしまっています。

Pocket

CONTACT

お問い合わせ・ご依頼はこちらから