ruby rails

YAMLファイルのkey構造が環境ごとに一致しているか検証する

ruby rails

最近スプラトゥーン 3 をやりこんでいて、サーモンランでカンスト(※)を達成しました todacchi です。

ゲーム内の貴重なバッジを獲得し、人生の貴重な時間を失いました。

(※) カンスト: ゲーム内の評価をでんせつ 999 まで上げること。

はじめに

Ruby on Rails を用いた Web アプリケーション開発などでは、YAML ファイルを設定ファイルとして採用し、環境ごとに切り替えるような運用をすることがあります。 しかし、まれに本番環境の部分だけ書き間違えてしまうといったことがあります。 そうすると、あえて本番環境の値を使わせるようなテストを意図的に書かない限りテストが落ちないですし、YAML の文法的には正しい場合もあるのでミスに気付きづらいです。 その結果、テストやステージング環境では正常に動作していても、本番環境にリリースすると障害が発生してしまいます。

例えば以下のような YAML ファイルがあるとします。

default: &default
  api:
    id: "api_id"
    key: "api_key"

development:
  <<: *default

test:
  <<: *default

integration:
  <<: *default

production:
  <<: *default
  api:
    id: "production_api_id"
    key: <%= ENV['API_KEY'] %>

この YAML ファイルを修正する時に production 内のインデントを間違えてしまったとしましょう。

default: &default
  api:
    id: "api_id"
    key: "api_key"

development:
  <<: *default

test:
  <<: *default

integration:
  <<: *default

production:
  <<: *default
  api:
    id: "production_api_id"
  key: <%= ENV['API_KEY'] %>

このとき、["production"]["api"]["key"]nilになってしまい、本番環境でこの値を使ってるコードがエラーになります。 hash に変換すると以下のような感じで nil になるのは当然ですね。

irb> y = <<~EOT
development: &default
  api:
    id: "api_id"
    key: "api_key"

test:
  <<: *default

integration:
  <<: *default

production:
  <<: *default
  api:
    id: "production_api_id"
  key: "api_key_from_env"
EOT

irb> h = YAML.safe_load(y, aliases: true)
=>
{
  "development"=>{"api"=>{"id"=>"api_id", "key"=>"api_key"}},
  "test"=>{"api"=>{"id"=>"api_id", "key"=>"api_key"}},
  "integration"=>{"api"=>{"id"=>"api_id", "key"=>"api_key"}},
  "production"=>{
    "api"=>{"id"=>"production_api_id"},
    "key"=>"api_key_from_env"
  }
}

irb> h['development']['api']['key']
=> "api_key"

irb> h['production']['api']['key']
=> nil

もちろん、コーディング中やレビュー時にミスのないように注意しますが、実務で作る YAML ファイルはたびたび複雑だったり巨大だったりするので先ほどの例のようにわかりやすくはありません。 また、人間がこういったミスがないかを確認する作業はとても生産的とは言えません。 ケアレスミスで起きる障害の芽は減らしていくべきでしょう。

そこで環境ごとに key の構造が同じかどうかを機械的に検証するコードを Gem にしてプロダクトに導入したので、その紹介をします。

作成した Gem

yosipy / yaml_structure_checker

簡単に説明すると key の構造が環境ごとに一致するか検証することができます。 Ruby on Rails を使ったプロジェクトの設定ファイルに対して使用できますが、それ以外のプロジェクトにおいても(root の key が環境名である)同じような YAML ファイルに対して使用することができます。 (もし「こういった形式の YAML ファイルに対しても使用したい」という意見がありましたら Issue を立ててもらえると対応できるかもしれません)

こういった YAML ファイルを検証するツールとして Kwalify のようにスキーマを定義して YAML ファイルをチェックする方法もあり、細かく YAML の構造をチェックできますがスキーマの管理に労力がかかります。 YAML structure checker では「環境ごとの YAML の構造の差異」に焦点を絞ることで、わずかな設定だけでチェックを行えるようにしました。

導入方法

Gemfile に以下を追加して$ bundle installします。

gem 'yaml_structure_checker'

config/yaml_structure_checker.ymlを作成して検証対象とする path や除外する path などを指定します。

# config/yaml_structure_checker.yml
include_patterns:
  - config/**/*.yml
exclude_patterns:
  - config/locales/**/*.yml
envs:
  - development
  - test
  - integration
  - production
skip_paths:
  - config/gcp.yml

最後に$ bundle exec yaml_structure_checkerを実行するだけです。

$ bundle exec yaml_structure_checker

#################################
#     YAML Structure Check      #
#################################

Exclude paths:
  spec/fixtures/checker/target/exclude/example.yml
  spec/fixtures/checker/target/failed/alias.yml
  spec/fixtures/checker/target/failed/diff.yml

Skip paths:
  spec/fixtures/checker/target/skip/example.yml


# spec/fixtures/checker/target/success/alias.yml
Result: OK


# spec/fixtures/checker/target/success/same.yml
Result: OK

#################################
#  YAML Structure Check Result  #
#################################

NG paths:
  nothing

Total count: 6
Exclude count: 3
Skip count: 1
OK count: 2
NG count: 0

より詳しい説明はReadmeをご覧ください。

ソースコードの解説

せっかくなので、コアな処理に絞って解説していきます。

Converter.hash_to_nested_keys

Hash に変換された YAML ファイルを引数に取り、Hash の key の親子関係を配列に変換していく再起メソッドです。 すべての再帰処理が終わったら全体を配列として返します。

引数の Hash の key 一覧を順に読み込んで、その値が Hash クラスであれば再起処理を行い、それ以外の場合は現在の key が一番深い key なので祖先の key 一覧に追加して返しています。

    def self.hash_to_nested_keys(
      hash,
      nested_keys = [],
      key_ancestors = []
    )
      keys = hash.keys
      keys.each do |key|
        value = hash[key]
        child_key_ancestors = nested_key = key_ancestors.clone.push(key)
        if value.class == Hash
          hash_to_nested_keys(value, nested_keys, child_key_ancestors)
        else
          nested_keys.push(nested_key)
        end
      end

      nested_keys
    end

言葉で説明するとややこしいですが、ソースコードのコメントに書いてあるように以下のような変換を行います。

  # args:
  # hash = {
  #   'development' => { 'x' => 1, 'y' => 2, 'z' => 3 },
  #   'test' => {
  #     'p' => { 'q' => { 'r' => 4 } },
  #     's' => { 't' => { 'u' => 5 } },
  #   },
  #   'production' => 6,
  # }

  # return:
  # [
  #   ['development', 'x'],
  #   ['development', 'y'],
  #   ['development', 'z'],
  #   ['test', 'p', 'q', 'r'],
  #   ['test', 's', 't', 'u'],
  #   ['production']
  # ]

Converter.nested_keys_to_nested_keys_each_env

hash_to_nested_keysで変換された一覧を環境ごとに分類します。 指定した環境以外の最上位 key があれば無視するようにしています。

    def self.nested_keys_to_nested_keys_each_env(nested_keys, envs)
      nested_keys_each_env = {}
      envs.each do |env|
        nested_keys_each_env[env] = []
      end

      nested_keys.each do |nested_key|
        nested_key = nested_key.clone
        env = nested_key.first
        nested_key_without_env = nested_key.drop(1)

        if envs.include?(env)
          nested_keys_each_env[env].push(nested_key_without_env)
        end
      end

      nested_keys_each_env
    end
  # args:
  # nested_keys = [
  #   ['development', 'x'],
  #   ['development', 'y'],
  #   ['development', 'z'],
  #   ['test', 'p', 'q', 'r'],
  #   ['test', 's', 't', 'u'],
  #   ['production']
  # ]
  # envs = ['development', 'test', 'production']

  # return:
  # {
  #   'development' => [['x'], ['y'], ['z']],
  #   'test' => [['p', 'q', 'r'], ['s', 't', 'u']],
  #   'production' => [[]],
  # }

Checker#compare

環境ごとに key の構造に差分がないか確認します。

Checker#test_yaml

YAML ファイルごとに検証を行います。

Checker#test_yamls

複数の YAML ファイルの検証を行うメソッドです。 構造チェックに失敗した YAML ファイルが 1 つでもあると例外を発生させます。

CLI コマンドとして実行できるようにする

Gem 開発の際、拡張子のないファイルを exe/におくことで作成できます。

yosipy/yaml_structure_checker/blob/main/exe/yaml_structure_checker

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'yaml_structure_checker'

if !ARGV.empty?
  settings_path = ARGV.first
end

YAMLStructureChecker::Runner.invoke(settings_path)

このファイルを読み込む処理は gemspec ファイルに記載されてます。

# frozen_string_literal: true

require_relative "lib/yaml_structure_checker/version"

Gem::Specification.new do |spec|
  ~ 省略 ~

  spec.bindir = "exe"
  spec.executables = %w[yaml_structure_checker]

  ~ 省略 ~

$ bundle exec yaml_structure_checker arg1 arg2のように実行するとARGV['arg1', 'arg2']のように渡ります。

CI で自動検証する

GitHub Actions を用いて Push されるたびに自動的に検証されるようにします。 コンテナ image はプロジェクトに合ったやつを使ってください。

bundle exec yaml_structure_checkerを実行するだけです。

jobs:
  yaml_structure_checker:
    runs-on: ubuntu-latest
    container:
      image: ruby:3.2.2

    steps:
      - uses: actions/checkout@v3
      - name: Install gems
        run: bundle install --path=vendor/bundle --jobs 4 --retry 3
      - name: Test
        run: bundle exec yaml_structure_checker

動いてますね 😉

CI実行結果

まとめ

YAML ファイルの key 構造を検証する Gem の紹介を行いました。 小さな改善ですが、開発の安心感は増したと思います。

最近は Gem 開発がちょっとしたマイブームとなっていて、思っていたより簡単に作れるのでぜひ手を出してみてください。

余談ですが、リポジトリの Readme の Top に貼ってる画像をこの記事のサムネにしていて、結構気に入ってたりします。

yosipy / yaml_structure_checker