YAML のタグ (!Ref など) を Ruby で扱う


AWS の CloudFormation ではリソースの管理を YAML でできるんだけど、そこでは下記のような記法が使える。

# See: https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html
InstanceId: !Ref ApplicationServer

この !Ref のように ! で始まる文字列は YAML の仕様にあるタグと呼ばれるもので、値の型を示すために使われる。
しかし、このような YAML を Ruby の標準ライブラリで読み込むとタグの情報は抜け落ちてしまう。

require "yaml"
yaml = "InstanceId: !Ref ApplicationServer"
parsed = YAML.load(yaml)

pp parsed
# {"InstanceId"=>"ApplicationServer"}

puts parsed.to_yaml
# ---
# InstanceId: ApplicationServer

YAML でタグを扱う方法を調べてみた。

YAML.add_domain_type

タグ付きの値を特定のクラスのオブジェクトとして読み込むだけならこれが楽そう。
下記は !Ip タグをつけた文字列を IPAddr オブジェクトとして読み込む例。

require "yaml"
require "ipaddr"

YAML.add_domain_type("", "Ip") do |type, value|
  IPAddr.new(value)
end

yaml = "ip: !Ip '127.0.0.1'"
parsed = YAML.load(yaml)
p parsed["ip"]
# #<IPAddr: IPv4:127.0.0.1/255.255.255.255>

ただし、これを dump すると普通に Ruby オブジェクトを dump した場合と同じになり、元のかたちにはならない。

puts parsed.to_yaml
# ---
# ip: !ruby/object:IPAddr
#   family: 2
#   addr: 2130706433
#   mask_addr: 4294967295

dump した場合の挙動を変更したい場合は値のクラスに encode_with インスタンスメソッドを定義する。

class IPAddr
  def encode_with(coder)
    # 第 1 引数はタグ、第 2 引数は値
    # 値がスカラーなので represent_scalar メソッドを使っている
    # リストなら represent_seq、マップなら represent_map を使う
    coder.represent_scalar "!Ip", to_s
  end
end

puts parsed.to_yaml
# ---
# ip: !Ip 127.0.0.1

YAML.add_tag

YAML.add_domain_type では YAML での値 -> オブジェクトの変換処理をブロックで指定していたが、値のクラスに持たせることもできる。
この場合は YAML.add_tag でタグとクラスの対応付けを行う。

require "yaml"
require "ipaddr"

class IPAddr
  # YAML の読み込み時に Class.allocate でオブジェクトが生成されたあと、このメソッドが呼ばれる。
  # Class.allocate では initialize が呼ばれないので initialize を呼ぶのが楽。
  def init_with(coder)
    # coder.scalar でスカラー値が得られる。リストなら coder.seq、マップなら coder.map。
    # coder.tag でタグを得ることもできる。
    initialize(coder.scalar)
  end

  # 上例と同じ
  def encode_with(coder)
    coder.represent_scalar "!Ip", to_s
  end
end

YAML.add_tag("!Ip", IPAddr)

yaml = "ip: !Ip '127.0.0.1'"
parsed = YAML.load(yaml)
p parsed["ip"]
# #<IPAddr: IPv4:127.0.0.1/255.255.255.255>

puts parsed.to_yaml
# ---
# ip: !Ip 127.0.0.1

CloudFormation のテンプレートでの実例

CloudFormation のテンプレートではタグを省略のために使っているだけなので、書き戻す必要がないのであれば、読み込み時に省略しないかたちに変換してしまうのが一番楽。

require "yaml"

YAML.add_domain_type("", "Ref") do |type, value|
  {"Ref" => value}
end

yaml = "InstanceId: !Ref ApplicationServer"
parsed = YAML.load(yaml)

pp parsed
# {"InstanceId"=>{"Ref"=>"ApplicationServer"}}

puts parsed.to_yaml
# ---
# InstanceId:
#   Ref: ApplicationServer

書き戻す必要があるならクラスを作って YAML.add_tag 使うのがよさそう。

require "yaml"

class Ref
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def init_with(coder)
    initialize(coder.scalar)
  end

  def encode_with(coder)
    coder.represent_scalar "!Ref", value
  end
end

YAML.add_tag("!Ref", Ref)
yaml = "InstanceId: !Ref ApplicationServer"
parsed = YAML.load(yaml)

pp parsed
# {"InstanceId"=>#<Ref:0x00007ff04009b838 @value="ApplicationServer">}

puts parsed.to_yaml
# ---
# InstanceId: !Ref ApplicationServer