あまり知られていないYAMLのタグについて

こんにちは、メディアサービス開発部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アプリケーションエンジニアを募集しています。

もし興味がありましたらぜひ、ブックウォーカーの採用情報ページからご応募ください。

参考文献


  1. YAML#loadが非互換となったことについてはこちらが詳しいです。
  2. allocateを使うとinitializeを呼ばずにインスタンスを生成できます。
  3. https://github.com/ruby/psych/blob/dbd058899672c399f9cd35927eac3a0c8b768df5/lib/psych/visitors/to_ruby.rb#L14
  4. YAML#loadと同様にYAML#load_fileにおいてもインスタンスにメンバ変数を自由に注入できてしまう点で注意が必要です。