📘

[Ruby] ERBを埋め込んだYAMLファイルを読み込む(Railsのdatabase.ymlみたいに!)

2021/02/18に公開

ERBを埋め込んだYAMLファイルを読み込むためのモジュールを作成します。
設定ファイルとしてYAMLファイルを用意した場合に便利です。

この記事の要点

  • 以下の感じで実装できます。パッと見でよくわからない場合は、実装・実行例のところを見てください

    require "yaml"
    require "erb"
     
    module ERBEmbeddedYAML
      module_function
    
      def load_file(yaml_path)
        erb_str = File.read(yaml_path)
        yaml = ERB.new(erb_str).result
       
        YAML.load(yaml)
      end
    end
    
  • Railsのrails/railties/lib/rails/application/configuration.rbのload_database_yaml辺りが参考になります

使い道

YAMLで設定ファイルを書く場合に...

  • 本番環境のDBの接続情報など、秘匿すべき情報を環境変数としてセットする
    • 設定ファイルは共有バージョン管理リポジトリ(GitHubなど)に管理したりするので、秘匿情報をそのまま直書きすると危険。そのため、環境変数を使ってプログラム実行時に値を渡すようにする
  • 「今日から30日前」など、プログラム実行時に動的に決まる値をパラメータとしてセットする

などなど。

Ruby on Railsの例

例えばRuby on Railsでは、config/database.ymlにて

  • 開発・テスト環境のDB接続情報は直書き
  • 本番環境のDB接続情報は、環境変数から取得

という風になっています。

config/database.ymlの一例(PostgreSQLをDBに採用した場合)
# 本題と関係ないので以下例では省略していますが、
# 開発・テスト環境DBのpassword, urlは、Rails側がよしなにしてくれます。

development:
  ...
  database: sample_rails_app_development
...

test:
  ...
  database: sample_rails_app_test
...

production:
  ...
  database: sample_rails_app_production
  username: sample_rails_app
  password: <%= ENV['SAMPLE_RAILS_APP_DATABASE_PASSWORD'] %>
  url: <%= ENV['SAMPLE_RAILS_APP_DATABASE_URL'] %>

Rubyファイルでよくない?

確かにrubyファイルでも同様のことはできますが、

  • 「パラメータの設定ファイルである」用途が明確で、利用者が戸惑いにくい
  • 利用者が余計なことをしにくい(「いじるべきはパラメータの値部分」と認識しやすいので)

という「いい意味で制限できる」点で、ERB埋め込みのYAMLの方がいいかなと個人的に思っています。

実装・実行サンプル

単純な実装サンプルとして、ユーザに挨拶をし、指定した期間の間に日曜日が何回あるかを教えてくれるプログラムを作成します(いいネタが思いつきませんでした)。

下記のような構成とします。

.
├── config
│   ├── dates.yml    # <- いつからいつまで、を設定する
│   └── username.yml # <- usernameを設定する(開発・本番環境それぞれ設定)
├── lib
│   ├── config_loader.rb     # <- 各config/*.yml専用の読み込みメソッド群を定義したモジュール
│   └── erb_embedded_yaml.rb # <- ERBを埋め込んだ任意のYAMLファイルを読み込むモジュール
└── main.rb

ERBを埋め込んだYAMLファイルを読み込むモジュール

この記事の中で、一番意味のある部分です。

ERBが埋め込まれた文字列としてYAMLファイルを読み込み (File.read(yaml_file))、ERBを実行して最終的に得られたYAML文字列 (ERB.new(erb_str).result)を、YAML.loadでHashなどYAMLの中身に応じたオブジェクトに変換します。

lib/erb_embedded_yaml.rb
require "yaml"
require "erb"

module ERBEmbeddedYAML
  module_function

  def load_file(yaml_file)
    # rails/railties/lib/rails/application/configuration.rbを参考にした
    erb_str = File.read(yaml_file)
    yaml = ERB.new(erb_str).result

    YAML.load(yaml)
  end
end

ちなみに、読み込んだYAMLファイルが空っぽの場合は、YAML文字列が空文字""になりますが、この空文字を引数として渡したYAML.load("")の結果は、空文字""ではなくfalseになります。
Rubyリファレンスマニュアル Psych.loadYAMLオブジェクトは実際はPsychオブジェクトなので、リンク先がPsych.loadになっています。)

これは、a || banilもしくはfalseの時だけb, それ以外はaを返す)の書き方を利用して、以下のようにデフォルト値を簡単に設定できるからだと思います。

loaded = YAML.load(yaml) || {} # YAML.load(yaml)がfalseの場合は、{}を代入する

今回の例では、Hashを返すYAMLのみを前提としているわけではないため使っていませんが、前述のRailsのコード中では使われているので、一度確認してみてください。

ERBを埋め込んだYAMLファイル

config/ディレクトリ配下に置いています。

dates.yml

いつから (start_at), いつまで (end_at) を設定します。
プログラム実行時に動的に変わる値Date.todayを使う例です。

  • ConfigLoader.datesで読み込む
    • Dateクラスのオブジェクトか、Date.strptimeで解釈できる文字列を設定できる
config/dates.yml
# --------------------------------------------------
# * 特定の日時を指定したい場合は、
#     end_at: 2030-01-30
#   のように、YYYY-mm-ddの書式で指定すること。
#
# * start_atはend_atより前の日を指すようにすること。
# --------------------------------------------------
start_at: <%= Date.today + 1 %>

# end_at: <%= Date.today + 30 %>
end_at: 2030-01-30 # <- 日付指定で書き換えている

Dateオブジェクトが書かれているだけだとわざわざ設定ファイルに切り出す意義がわかりづらいので、「特定の日時でend_atを書き換えた」パターンの内容にしています。

また、面倒くさいので「start_atをend_atより前の日にする」のはユーザに任せています(エラー処理を書いていない)。

username.yml

挨拶するユーザ名 (username) を設定します。
環境変数の値を使う例です。

  • ConfigLoader.usernameで読み込む
    • 環境変数APP_ENVに指定する実行環境によって、読み込まれる設定が変わる(ように読み込みモジュールを書く)ので、開発環境と実行環境両方のusername設定を記載する
      • 開発環境 (development) の場合は、developer
      • 本番環境 (production) の場合は、環境変数APP_USERNAMEに設定した文字列
config/username.yml
development:
  username: developer

production:
  username: <%= ENV['APP_USERNAME'] %>

(プログラム的に、設定ファイルにする意味は全然ないですが、サンプルということで無理やりです。)

config/配下の各設定YAMLファイルを読み込むモジュール

コードが長いですが、長さのわりに大したことはしていないので、まずは以下だけ認識して、次のmain.rbと実行結果を見た方がよいです。

  • config/配下の各設定YAMLファイル(dates.yml, username.yml)に対し、ConfigLoaderモジュール内に、それぞれ専用の読み込みメソッドを定義している
    • ConfigLoader.datesでconfig/dates.ymlを読み込み、{"start_at" => #<Date: 2021-02-18>, "end_at" => #<Date: 2030-01-30>}のようなHashを返す
    • ConfigLoader.usernameでconfig/username.ymlを読み込み、環境変数APP_ENVの値によってセクションを選択して、{"username" => "developer"}のようなHashを返す
lib/config_loader.rbの補足
  • config_switched_by_envは、環境変数APP_ENVで指定された実行環境に合わせたconfigを抜き出します
    • ConfigLoader.usernameで使われています
  • datenは、config/dates.ymlのstart_at, end_atをDateクラスに変換します
    • 名前は適当です
    • 設定YAMLファイルというよりはconfig/dates.yml特有の事情なので、本当はConfigDatesクラスのようなものを定義して、その中でデータの性質として処理した方が見通しが良いと思います
  • ConfigLoaderモジュール内にconfig/*.ymlを読み込むメソッドを一括で定義するので、YAMLファイルのパスをここにハードコードしてもいいかな、という気持ちです。
lib/config_loader.rb
require "date"
require_relative "erb_embedded_yaml.rb"

module ConfigLoader
  def self.config_switched_by_env(loaded_config)
    # 環境変数APP_ENVで指定された実行環境に合わせたconfigを抜き出す

    # 環境変数APP_ENVの値を変数app_envにセット;指定がない場合は"development"
    app_env = ENV['APP_ENV'] || "development"

    if app_env.downcase == "production"
      loaded_config["production"]
    else # 無効な値の場合も含め、デフォルトではdevelopmentのセクションを返す
      loaded_config["development"]
    end
  end

  def self.daten(obj)
    # objがStringならばDateに変換、そうでないならば(元からDateと仮定して)そのまま
    if obj.class == String
      Date.strptime(obj)
    else
      obj
    end
  end

  module_function

  def dates
    config_path = "config/dates.yml"

    config = ERBEmbeddedYAML.load_file(config_path)

    # start_at, end_atをDateクラスに揃えておく
    config["start_at"] = daten(config["start_at"])
    config["end_at"] = daten(config["end_at"])

    config
      # cf.) -> {"start_at" => #<Date: 2021-02-18>, "end_at" => #<Date: 2030-01-30>}
  end

  def username
    config_path = "config/username.yml"

    loaded_config = ERBEmbeddedYAML.load_file(config_path)
    # 実行環境APP_ENVにしたがって、パラメータを抜き出す
    config = config_switched_by_env(loaded_config)

    # APP_ENV=productionの場合は、APP_USERNAMEでusernameを指定しなければならない
    # ...が、その指定がないときはnilになっているので、"nobody"にする
    config["username"] = config["username"] || "nobody"

    config # cf.) -> {"username" => "developer"}
  end
end

メインプログラムmain.rb

YAMLファイルに設定した値は、lib/config_loader.rbのConfigLoader.dates, ConfigLoader.username経由で取得しています。

main.rb
require_relative "lib/config_loader.rb"
require "date"

def main
  dates = ConfigLoader.dates
    # cf.) -> {"start_at" => #<Date: 2021-02-18>, "end_at" => #<Date: 2030-01-30>}
  start_at = dates["start_at"] # -> Date
  end_at = dates["end_at"] # -> Date

  username = ConfigLoader.username["username"] # -> String

  # start_atからend_atの間に存在する日曜日を集める
  sundays = (start_at..end_at).select { |date| date.sunday? } # -> Array of Date

  # 出力
  puts "こんにちは、#{username}さん"
  puts "#{start_at}から#{end_at}の間には、日曜日が#{sundays.size}回あります!"
  puts "直近だと、"
  sundays[0...3].each do |date|
    puts "  #{date}"
  end
  puts "ですね!"
end
                              
# 実行
main

本筋とはあまり関係ないですが、

  • start_at..end_atは、DateオブジェクトからなるRangeオブジェクトです(..なので、終端end_at含む範囲)

  • 出力のところのsundays[]の引数0...3も、IntegerオブジェクトからなるRangeオブジェクトです(...なので、終端3含まない範囲)

    RangeやRange#selectの部分がいまいちピンとこない方向け補足

    Rubyリファレンスマニュアル Rangeクラスを見て、コード例やその改造版をirbで実行してみるとよくわかると思います。

    start_at..end_atRange#select(Enumetableから継承しているメソッドなので本当はEnumerable#selectの部分は、例えば以下をirbなどで実行したりいじったりしてみてください。(rubyファイルに保存して実行した方が見やすいかも。)

    require "date"
    
    start_at = Date.new(2021, 4, 1)
    end_at = Date.new(2021, 4, 14)
    
    date_range = start_at..end_at
    
    date_range.each { |date| puts "[#{date}] date.sunday: #{date.sunday?}" }
    
    sundays = date_range.select { |date| date.sunday? }
    puts "sundays: #{sundays}"
    
  • このmainメソッドを定義するやり方は、伊藤淳一さんの「Rubyスクリプトにもmainメソッドを定義するといいかも、という話」という記事を参考にしています。
    (今回は内部でのメソッド切り出しもしていないですが、あとで分けようとなる場合に面倒くさくなるので、初めからmainメソッドを定義しています。)

実行例

環境変数でusernameをコントロールするようにしたので、幾つかの状況で実行する例を載せます。これ以外にも、APP_ENV=3で実行など、いじって実験してみてください。
(あまり有意義なプログラムではないので、特筆事項は特にないです。数にすると日曜日が意外と少なく見えることくらいかな...)

環境変数をセットしないで実行

  • config/username.ymlのdevelopmentセクションを使用
    => usernameはdevelopment
$ ruby main.rb
こんにちは、developerさん
2021-02-18から2030-01-30の間には、日曜日が467回あります!
直近だと、
  2021-02-21
  2021-02-28
  2021-03-07
ですね!

環境変数APP_ENVをproduction, APP_USERNAMEをIchiroにして実行

  • config/username.ymlのproductionセクションを使用
    => usernameはAPP_USERNAMEから読み込み、Ichiroに
$ APP_ENV=production APP_USERNAME=Ichiro ruby main.rb
こんにちは、Ichiroさん
2021-02-18から2030-01-30の間には、日曜日が467回あります!
直近だと、
  2021-02-21
  2021-02-28
  2021-03-07
ですね!

環境変数APP_ENVをproduction, APP_USERNAMEは指定なしで実行

  • config/username.ymlのproductionセクションを使用
    => usernameはAPP_USERNAMEから読み込むが、指定がないのでConfigLoader.username内でnobodyに
$ APP_ENV=production ruby main.rb
こんにちは、nobodyさん
2021-02-18から2030-01-30の間には、日曜日が467回あります!
直近だと、
  2021-02-21
  2021-02-28
  2021-03-07
ですね!

おわりに

Railsに感謝

YAMLにERBを埋め込むやり方はとても便利なので、教えてくれたRailsにはとても感謝です。

この記事のモチベーションの半分くらいは、Railsのどこに読み込みメソッドが定義されているかをメモしておくことにあります笑

もっといい設定ファイルのやり方があれば、是非教えてください!

私の場合、普通のRubyプロジェクトでも、

  • config/ディレクトリを作成してsmtp.yml, database.ymlなど接続情報を実行環境別に用意
    • 開発・テスト環境の接続情報は(ローカルの場合は)直書き
    • 本番環境の接続情報は、環境変数で受け取る
  • config/*.ymlを(実行環境に合わせて)読み込むモジュールメソッドを、ひとつのConfigLoaderのような名前のモジュール内にまとめて、YAMLファイル別に作成
    • データの性質が複雑な時は、Structもしくはクラスを作成
    • YAMLのファイルパスは、専用読み込みモジュールメソッドかクラス内にハードコード

という、今回のサンプルプロジェクトのような構成をよく取ります。

ただ、Railsの形を借りてきた自己流なので、特に設定ファイルの読み込み方に関して「こっちのほうがいいよ」「メジャーなのはこのやり方だよ」「このやり方はこの点でまずいよ」というのがありましたら、ご指摘いただけると嬉しいです。

Discussion