こんにちは、メディアサービス開発部Webアプリケーション開発課のシゲタです。
YAMLには値に型を明示するためのタグという仕様があります。 RubyのYAMLライブラリもこれを拡張した機能を提供しており、タグで指定したクラスのインスタンスに値を変換することができます。面白い機能ではありますがあまり知られていなさそうなので、今回ご紹介したいと思います。
タグを指定する
YAMLにタグを指定する場合には、以下のように!ruby/クラス名
という形式で記述します。スペースを挟んだ次の値がインスタンスの初期値になります。
require 'yaml' YAML.load(<<~EOS, permitted_classes: [Regexp, Range]) --- array: !ruby/array [1, 2, 3] hash: !ruby/hash {foo: 1, bar: 2} regexp: !ruby/regexp /foo|bar/ range: !ruby/range 1..10 EOS => {"array"=>[1, 2, 3], "hash"=>{"foo"=>1, "bar"=>2}, "regexp"=>/foo|bar/, "range"=>1..10}
結果から値がタグに指定したクラスのインスタンスに変換されていることがわかります。 第2引数のpermitted_classesはYAMLがデフォルトで変換を許容していないクラスを指定する際に必要な引数です。
以下はデフォルトで変換を許容しているクラスです。
TrueClass FalseClass NilClass Numeric String Array Hash
permitted_classesはruby3.1からYAML#loadが非互換になったことにより必要になった引数なので、ruby3.1未満のバージョンをお使いの方はpermitted_classesの指定は不要です。1
独自定義クラスをタグに指定する
独自定義したクラスもタグに指定することができます。タグは!ruby/object:クラス名
のように指定します。
require 'yaml' module Foo class Hoge def initialize(bar) @bar=bar end end end YAML.load(<<~EOS, permitted_classes: [Foo::Hoge]) --- hoge: !ruby/object:Foo::Hoge bar: 123 EOS => {"hoge"=>#<Foo::Hoge:0x00000001064a64b8 @bar=123>}
メンバ変数の注入
動かしてみて分かったのですが、YAML#loadではインスタンス生成時に外からメンバ変数を自由に注入することができてしまいます。
require 'yaml' module Foo class Hoge def initialize(bar) @bar=bar end end end YAML.load(<<~EOS, permitted_classes: [Foo::Hoge]) --- hoge: !ruby/object:Foo::Hoge hoge: 123 baz: 456 EOS => {"hoge"=>#<Foo::Hoge:0x00000001063cd618 @baz=456, @hoge=123>}
上記を見てみると、Foo::Hoge#initializeの引数に定義されていない値がインスタンスのメンバ変数として設定されています。
これはYAMLがバックエンドとして利用しているPsychによる挙動です。YAML#loadが内部的に呼んでいるPsych#loadのコードを追っていくと、どうやら変換時のインスタンス生成をallocate2で行い、さらに生成したインスタンスに対してinstance_variable_setでメンバ変数を設定しているようです。3
このために、initializeは実行されることはなく外からメンバ変数を注入できてしまうというわけです。
ユースケース
ユースケースとして何があるか考えてみたのですが、テストで外部APIのレスポンスをモックしたい場合に使えそうです。
以下はNotionのRubyクライアントgemであるnotion-ruby-clientのNotion::Client#block_childrenが返すAPIレスポンスの一部です。
#<Notion::Messages::Message block=#<Notion::Messages::Message> has_more=false next_cursor=nil object="list" results=#<Hashie::Array [ #<Notion::Messages::Message archived=false bulleted_list_item=#<Notion::Messages::Message color="default" rich_text=#<Hashie::Array [ #<Notion::Messages::Message annotations=#<Notion::Messages::Message bold=false code=false color="default" italic=false strikethrough=false underline=false > href=nil plain_text="hoge" text=#<Notion::Messages::Message content="hoge" link=nil > type="text" > ] > > created_by=#<Notion::Messages::Message id="xxx-xxx-xxx-xxx-xxx" object="user" > created_time="xxx-xxx-xxx-xxx-xxx" has_children=false id="xxx-xxx-xxx-xxx-xxx" last_edited_by=#<Notion::Messages::Message id="xxx-xxx-xxx-xxx-xxx" object="user" > last_edited_time="2022-12-03T08:45:00.000Z" object="block" parent=#<Notion::Messages::Message page_id="xxx-xxx-xxx-xxx-xxx" ...
このように外部APIのレスポンスが独自クラスでラップされており、ネストも深いといった場合にモックをどうやって作成するか悩みどころではないでしょうか。 こういった際、以下のようなYAMLファイルをテストのconfigでグローバル変数として読み込んでしまえばモックとして利用することができます。
--- !ruby/hash:Notion::Messages::Message block: !ruby/hash:Notion::Messages::Message {} has_more: false next_cursor: object: list results: !ruby/array:Hashie::Array - !ruby/hash:Notion::Messages::Message id: xxx-xxx-xxx-xxx-xxx archived: false parent: !ruby/hash:Notion::Messages::Message type: page_id page_id: xxx-xxx-xxx-xxx-xxx created_time: 2022-12-03T08:38:00.000Z last_edited_time: 2022-12-03T08:45:00.000Z created_by: !ruby/hash:Notion::Messages::Message object: user id: xxx-xxx-xxx-xxx-xxx last_edited_by: !ruby/hash:Notion::Messages::Message object: user id: xxx-xxx-xxx-xxx-xxx has_children: false type: bulleted_list_item bulleted_list_item: !ruby/hash:Notion::Messages::Message color: default rich_text: !ruby/array:Hashie::Array - !ruby/hash:Notion::Messages::Message annotations: !ruby/hash:Notion::Messages::Message bold: false code: false color: default italic: false strikethrough: false underline: false href: plain_text: hoge text: !ruby/hash:Notion::Messages::Message content: hoge link: type: text created_by: !ruby/hash:Notion::Messages::Message id: xxx-xxx-xxx-xxx-xxx object: user created_time: 2022-12-03T08:38:00.000Z has_children: true id: xxx-xxx-xxx-xxx-xxx object: "user" last_edited_by: !ruby/hash:Notion::Messages::Message id: xxx-xxx-xxx-xxx-xxx object: user last_edited_time: 2022-12-03T08:45:00.000Z object: block parent: !ruby/hash:Notion::Messages::Message page_id: xxx-xxx-xxx-xxx-xxx type: page_id type: bulleted_list_item ...
# spec_helper.rb BLOCK_CHILDREN = YAML.load_file( 'spec/fixtures/block_children.yml', permitted_classes: [Notion::Messages::Message, Hashie::Array, Time] ) # xxx_spec.rb before do # Notion::Client#block_childrenが呼ばれたらBLOCK_CHILDRENを返すモックを作成 client = double('Notion::Client', block_children: BLOCK_CHILDREN) allow(Notion::Client).to receive(:new).and_return(client) end
rspecであればspec_helperでYAMLファイルを読み込むことで、生成したオブジェクトをテストから参照することができます。4
モックとなるオブジェクトをYAMLによって生成することで、テスト実行前の状況設定において複雑な構造のモックを自前で組み立てる処理を省略することができます。 ベターかどうかは横に置いておいて選択肢の一つにはなり得るかなと思います。
まとめ
あまり知られていさそうなYAMLの仕様であるタグについてご紹介しました。積極的に使う場面は少なそうですが、知っておくといつか役立つかもしれません。
最後に
ブックウォーカーでは物理・電子・Web連載問わず漫画や本が好き、あるいは長年運用されてきたWebサービスを紐解き、より良い形に作り替えていくことに興味があるWebアプリケーションエンジニアを募集しています。
もし興味がありましたらぜひ、ブックウォーカーの採用情報ページからご応募ください。