hypermkt blog

Protocol Buffersのwrappers.protoによる副作用とその解決法

March 08, 2020

はじめに

Protocol Buffersの標準型を利用した場合、エレメントに値がセットしないと初期値が設定される仕様があります。これはドキュメントのこちらで説明されています。しかし、この仕様では nil 判定ができないという問題がありました。

そこでgRPC Pluginとして提供されている wrappers.proto を使用することで、nil 判定が可能となりましたが、ある副作用がありました。本記事ではその副作用とその解決方法について紹介します。

前提

以下の環境で検証をしました。

  • Ruby 2.6.1
  • Gem
    • protobuf 3.11.4
    • grpc-tools 1.27.0

nil判定できない問題とwrappers.protoについて

本件の前提知知識として簡単に紹介します。例えば以下のように定義します。

syntax = "proto3";

message SampleMessage {
  int64 age = 1;
  string name = 2;
  bool has_job = 3;
}

値が未設定の場合は、型に応じた初期値がセットされてしまいます。

irb(main):002:0> SampleMessage.new
=> <SampleMessage: age: 0, name: "", has_job: false>

それであれば、 nil をセットすれば良いと思いますが、nilはセットされず、それでも初期値が設定されてしまします。

irb(main):004:0> SampleMessage.new(age: nil, name: nil, has_job: nil)
=> <SampleMessage: age: 0, name: "", has_job: false>

つまり、 nil 判定ができないので、故意にセットされた値なのか、未セットなのか判定できないという問題が発生します。

そこで wrappers.protoを使えば nil 判定できる

wrappers.proto で提供される Google::Protobuf::StringValue という型を利用することで、

syntax = "proto3";

import "google/protobuf/wrappers.proto";

message SampleMessage2 {
  google.protobuf.Int64Value age = 1;
  google.protobuf.StringValue name = 2;
  google.protobuf.BoolValue has_job = 3;
}

値が未セットの場合は nil になり、これで値の nil 判定が可能になりました!

irb(main):016:0> SampleMessage2.new
=> <SampleMessage2: age: nil, name: nil, has_job: nil>

wrappers.protoについては、以下のブログ記事で詳しく解説されていますのでご参考ください。

wrappers.protoで万事解決と思われたが副作用があった

しかし、wrappers.proto で提供される型を使用した場合に副作用がありました。値をセットする際に Google::Protobuf::Int64Value.new(value: 10) のようにwrappers.protoで定義されている型別のオブジェクトを渡す必要になり、オブジェクト構造も変わりました。クラス名が地味に長い…

irb(main):017:0> foo = SampleMessage2.new(age: Google::Protobuf::Int64Value.new(value: 10), name: Google::Protobuf::StringValue.new(value: 'yamada'), has_job: Google::Protobuf::BoolValue.new(value:true))
=> <SampleMessage2: age: <Google::Protobuf::Int64Value: value: 10>, name: <Google::Protobuf::StringValue: value: "yamada">, has_job: <Google::Protobuf::BoolValue: value: true>>

値を取得するときは .value を指定しないといけなくなってしまった。面倒くさい!

irb(main):018:0> foo.age.value
=> 10

Messageオブジェクトをハッシュ化すると以下になります。何が一番困るかというと、通常のkey/valueなハッシュ構造であれば、そのままActiveRecordに渡すことができましたが、以下の構造だとそのまま渡すことができません。これが今回の本題です。

irb(main):020:0> foo.to_h
=> {:age=>{:value=>10}, :name=>{:value=>"yamada"}, :has_job=>{:value=>true}}

解決策

{foo: {value: "bar"} } の構造を{foo: "bar"} に変換するため、再帰的にハッシュを精査して該当の箇所を愚直に変換するようにしました。

def extract_values(obj)
  case obj
  when Hash
    obj.compact.transform_values do |v|
      if v.kind_of?(Array)
        self.extract_values(v)
      else
        v[:value]
      end
    end
  when Array
    obj.map { |v| self.extract_values(v) }
  else
    obj
  end
end

このメソッドにハッシュを渡すと以下のように {foo: {value: "bar"} }{foo: "bar"} に変換してくれます!

irb(main):039:0> foo.to_h
=> {:age=>{:value=>10}, :name=>{:value=>"yamada"}, :has_job=>{:value=>true}}
irb(main):040:0> extract_values(foo.to_h)
=> {:age=>10, :name=>"yamada", :has_job=>true}

ハッシュの中に配列があっても問題なしです。万事解決!

irb(main):043:0> a = {foo1: {value: 'bar1'}, foo2: [{hoge2: {value: 'bar2'}}]}
=> {:foo1=>{:value=>"bar1"}, :foo2=>[{:hoge2=>{:value=>"bar2"}}]}
irb(main):044:0> extract_values(a)
=> {:foo1=>"bar1", :foo2=>[{:hoge2=>"bar2"}]}

他にも良い方法がありそうですが、今の所これ以上の方法が思いつきませんでした。もし他の方法がありましたら教えてもらえると嬉しいです。

まとめ

Protocol Buffersでnil判定をした場合には、wrappers.protoに定義された型を使用することで解決をすることができます。しかし、それには構造変化の副作用がありましたが、再帰的にデータを精査して変換処理をかけることで、シンプルな構造に変換する事例を紹介しました。もし同様な悩みを抱えている人がいたら参考になれば幸いです。


都内で働くWebアプリケーションエンジニア。主にサーバーサイド。最近はRuby/Railsでコードを書くのが楽しい。