問題01の解答と解説

2015/12/08

解答

先月(2015年11月)の演習問題アプリケーションの設定をデータベースで管理するの出題者(黒田)による解答例です。

4 回に分けて GitHub のリポジトリにコミットしてありますので、ソースコードの差分をご覧ください。

  • 04a201e ApplicationSetting モデルを作成
  • 34a43fa 管理者によるアプリケーション設定機能
  • 0149776 設定されたアプリケーション名の表示
  • ea53399 設定に基づくセッションタイムアウト制御

このページは「Rails 演習問題」という連載の一部です。詳しくは、はじめにをお読みください。

解説

今回、演習問題を解いて送ってくれたのは 1 名だけ、という若干寂しい結果となりました。

その 1 名とはたかゆきさん。送っていただいたソースコードの差分は 43ab427 で見ることができます。

基本的な方針は、私の解答例と同じです。アプリケーションの 2 つの設定項目(アプリケーション名とセッションタイムアウトまでの時間)を記録するためのデータベーステーブルを作り、そこに 1 つだけ行を挿入するというものです。

私もたかゆきさんも、application_settings というテーブルを作りました。

1 つだけの行を持つテーブルを定義するのは大げさなようですが、私はこれでいいと思います。設定項目の数が増えるたらデータベースマイグレーションを実行するだけです。設定項目が増えすぎて扱いづらくなったらテーブルを分割すればいいでしょう。

さて、私とたかゆきさんの実装で大きく異なる点がひとつあります。それは、application_settings テーブルに「唯一の行」をどのように挿入するかです。

たかゆきさんは、Admin::Base コントローラに次のようなプライベートメソッドを定義しました。

  def initialize_application_setting
    unless ApplicationSetting.exists?
      ApplicationSetting.create(application_name: "Baukis", expiration_of_session: "60")
    end
  end

これを before_action コールバックに登録しています。

悪くないのですが、改善の余地があります。

まず、この書き方だと application_settings というテーブルに 2 つ以上の行が挿入される可能性がわずかながら存在してしまいます。このアプリケーションが最初に起動した後で、複数の管理者がほぼ同時にアクセスした場合です。そんな可能性はほとんどないと思いますが、ちょっと厳密さを欠くように思えます。

また、管理者ページにアクセスがあるたびに application_settings というテーブルが空かどうかチェックするのは無駄です。

そこで、私は config/initializers ディレクトリに初期化スクリプト applicationsettings.rb を置き、その中で applicationsettings テーブルに最初の(そして最後の)行を挿入することにしました。

はじめに私が書いたのは次のようなコードです:

if ApplicationSetting.count == 0
  ApplicationSetting.create!(
    application_name: 'Baukis',
    session_timeout: 60
  )
end

アプリケーションの初期化フェーズでは「同時アクセス」の問題は起きないので安全ですし、データベースアクセスの無駄も解消されます。

しかし、思いがけない問題に遭遇しました。

まだ application_settings テーブルが存在しない場合にエラーが発生するという問題です。Rails は ApplicationSetting という定数が出てきたところでデータベースからテーブル定義を取得しようとし、例外が発生します。

つまり、このアプリケーションを新たな環境に設置した直後に bin/rake db:setup を実行できないのです。

そこで、私は StackOverflow のこの回答を参考にして、さきほどのコードを次のように書き換えることで問題を回避しました:

if ActiveRecord::Base.connection.tables.include?('application_settings')
  ApplicationSetting.count == 0
  ApplicationSetting.create!(
    application_name: 'Baukis',
    session_timeout: 60
  )
end

この種のテクニックは、Rails の標準的な教科書には載っていませんね。勉強になりました。


解説(追記)

…と、以上のように書いて Web サイトに公開したわけであるが、実は公開した直後にもっとストレートで分かりやすい方法があることに気が付きました。

application_settings テーブルの「唯一の行」をシードデータとして投入すればいい、ということに。

Rake タスク db:seed はまさにそのためにあります。なぜ、そのことに気付かなかったのでしょう。

しかし、もう午前 3 時を過ぎていて疲れていたのでいったん寝ることにしました。

そして、朝起きてすぐに次のようにソースコードを修正しました。

  • d6eafc3 db:seed で application_settings テーブルの初期化
  • a9ffad6 db/seeds/application_settings.rb を簡素化

せっかく覚えたテクニックは不要になってしまいましたが、この方がいいですね。

ただし、このアプリケーションがすでに運用中である場合は、別のアプローチが必要です。なぜなら、シードデータの投入はアプリケーションの設置直後に一度だけしか実行できないからです。

この場合は、マイグレーションスクリプトの中でデータを投入します。

  • 4ae6214 マイグレーションで application_settings を初期化
class CreateApplicationSettings < ActiveRecord::Migration
  def change
    create_table :application_settings do |t|
      t.string :application_name, null: false
      t.integer :session_timeout

      t.timestamps null: false
    end

    ApplicationSetting.create!(
      application_name: 'Baukis',
      session_timeout: 60
    )
  end
end

しかし、私の考えではマイグレーションスクリプトの中でモデルクラスを利用するのは得策ではありません。将来 application_settings テーブルの名前を変えたり、削除したりする可能性があります。その場合、ApplicationSettings クラス自体が存在しないことになるので、それを利用しているマイグレーションスクリプトが動かなくなるからです。

最新のデータベーススキーマが db/schema.rb に記録されていることを考えれば、過去のマイグレーションスクリプトは動かなくてもよい、という考え方もあるかもしれません。しかし、私は開発中に bin/rake db:migrate:reset コマンドをよく使うので、それでは困ります。

そこで、生の SQL を発行するようにマイグレーションスクリプトを書き換えます。

class CreateApplicationSettings < ActiveRecord::Migration
  def change
    create_table :application_settings do |t|
      t.string :application_name, null: false
      t.integer :session_timeout

      t.timestamps null: false
    end

    timestamp = Time.current.to_s(:db)
    execute(%Q{
      INSERT INTO application_settings
        (application_name, session_timeout, created_at, updated_at)
        VALUES ('Baukis', 60, '#{timestamp}', '#{timestamp}')
    })
  end
end

しかし、単にこのように書くとマイグレーションをロールバックするときに execute メソッドのところで例外 ActiveRecord::IrreversibleMigration が発生してしまいます。

この問題を回避するには、次のように書いてください。

  • f6539f9 生 SQL で application_settings を初期化
class CreateApplicationSettings < ActiveRecord::Migration
  def change
    create_table :application_settings do |t|
      t.string :application_name, null: false
      t.integer :session_timeout

      t.timestamps null: false
    end

    reversible do |dir|
      dir.up do
        timestamp = Time.current.to_s(:db)
        execute(%Q{
          INSERT INTO application_settings
            (application_name, session_timeout, created_at, updated_at)
            VALUES ('Baukis', 60, '#{timestamp}', '#{timestamp}')
        })
      end
    end
  end
end

実を言えば、この追記を書くまで reversible メソッドのことを私は知りませんでした。Google 検索で回避策を調べる過程で Reversible migrations with Active Record という記事に教えてもらいました。Rails 4.0 で導入されたのですね。またひとつ勉強になりました。