RedmineのSettingクラスのコードを読んでみる


My Redmine

今週のブログ担当は石川です。
RedmineはRuby on Railsでできているオープンソースソフトウェアです。コードが公開されているので誰でもコードを読むことができます。RedmineのSettingクラスの実装があまり見ない感じで面白かったので紹介します。

2022/10/11時点で最新のコード(r21901)についての記事です。本記事はコードを読んで挙動や意図などを推測したものなので、知識不足などのため正確でない内容が含まれているかもしれません。

Redmineの設定(管理者のみが操作可能)

SettingクラスにはRedmineで管理者のみが操作できる設定画面に関するコードが書かれています。一般ユーザーではあまり見ることの無い部分ですが、多数のRedmine全体に対する設定を行うことができます。設定の種類は100種類以上あります。


設定画面のスクリーンショット

Settingテーブル

テーブルはこのようにシンプルな構造で、100以上の項目すべてをカラムとして持っているのでは無く設定単位でレコードを持ちnameとvalueを持っています。

  create_table "settings", id: :serial, force: :cascade do |t|
    t.string "name", limit: 255, default: "", null: false
    t.text "value"
    t.datetime "updated_on"
    t.index ["name"], name: "index_settings_on_name"
  end

例えば、テキスト書式(なし、Textile、Markdown、CommonMark Markdonの4つのうちのどれか)の設定は、id: *, name: "text_formatting", value: "common_mark", updated_on: *** のようなデータになっています。

画面にチェックボックスで表示されるようなtrue/falseの値を持つ設定の場合は id: *, name: "thumbnails_enabled", value: "1", updated_on: ***のようにvalueが'0'か'1'の値を持つデータになります。

Settingクラス

メソッド一覧

app/models/setting.rb:
  107:   def value
  118:   def value=(v)
  124:   def self.[](name)
  128:   def self.[]=(name, v)
  137:   def self.set_all_from_params(settings)
  161:   def self.validate_all_from_params(settings)
  199:   def self.set_from_params(name, params)
  218:   def self.commit_update_keywords_from_params(params)
  235:   def self.twofa_from_params(params)
  241:   def self.twofa_required?
  245:   def self.twofa_optional?
  249:   def self.twofa_required_for_administrators?
  254:   def self.per_page_options_array
  259:   def self.commit_update_keywords_array
  279:   def self.check_cache
  287:   def self.clear_cache
  293:   def self.define_plugin_setting(plugin)
  303:   def self.define_setting(name, options={})
  307:       def self.#{name}
  311:       def self.#{name}?
  315:       def self.#{name}=(value)
  322:   def self.load_available_settings
  328:   def self.load_plugin_settings
  339:   def force_utf8_strings(arg)
  359:   def self.find_or_default(name)

config/settings.ymlを初期値として扱う仕組み

設定の初期値はconfig/settings.ymlを読み込んでいます。初期はSettingレコードが存在しない状態ですが、設定画面で一度でも保存ボタンを押すと表示している設定タブ内の設定をデータとして保存します。config/settings.ymlはあくまでも初期値であり、画面上で保存ボタンを押していて既にその設定のデータができている場合はそちらが優先されるため、後からファイルを書き換えても思ったように設定が反映されないことの方が多いと思います。

config/settings.yml

app_title:
  default: Redmine
welcome_text:
  default:
login_required:
  default: 0
  security_notifications: 1

# 以下省略

次のようにSettingクラスにはfind_or_defaultメソッドが定義されていて、その中でデータが存在しなければconfig/settings.ymlに入っているデフォルトの値(available_settings[name]['default'])をvalueにセットした未保存のインスタンスを返すようになっています。

上に書いた「config/settings.ymlはあくまでも初期値であり、画面上で保存ボタンを押していて既にその設定のデータができている場合はそちらが優先されるため、後からファイルを書き換えても思ったように設定が反映されないことの方が多いと思います」という挙動になるのはこのコードによるものです。

app/models/setting.rb

  # Returns the Setting instance for the setting named name
  # (record found in database or new record with default value)
  def self.find_or_default(name)
    name = name.to_s
    raise "There's no setting named #{name}" unless available_settings.has_key?(name)

    setting = where(:name => name).order(:id => :desc).first
    unless setting
      setting = new
      setting.name = name
      setting.value = available_settings[name]['default']
    end
    setting
  end
  private_class_method :find_or_default
end

Setting.define_setting

Settingクラスには、動的にメソッドを生成するコードが入っています。メタプログラミングや黒魔術と呼ばれるような書き方ですね。

app/models/setting.rb

  # Defines getter and setter for each setting
  # Then setting values can be read using: Setting.some_setting_name
  # or set using Setting.some_setting_name = "some value"
  def self.define_setting(name, options={})
    available_settings[name.to_s] = options

    src = <<~END_SRC
      def self.#{name}
        self[:#{name}]
      end
      def self.#{name}?
        self[:#{name}].to_i > 0
      end
      def self.#{name}=(value)
        self[:#{name}] = value
      end
    END_SRC
    class_eval src, __FILE__, __LINE__
  end

Setting.define_settingはnameに'thumbnails_enabled'、optionsには{'default' => '1'}が入るような使い方をされるメソッドです。

name: 'thumbnails_enabled'、options: {'default' => '1'} のように仮変数を渡された場合、ヒアドキュメントで書かれているEND_SRCからEND_SRCまでの文字列の変数部分がthumbnails_enabledに置き換わり、class_evalによって次の3つのメソッドが生成されます。

class_eval: https://docs.ruby-lang.org/ja/latest/method/Module/i/class_eval.html

class_evalの使用例

class Sample; end

str = ''
3.times {|i| str += "def self.method#{i}; p #{i}; end;" }
Sample.class_eval(str)

Sample.method0 # => 0
Sample.method1 # => 1
Sample.method2 # => 2

このようにメソッドを自動生成することで、100以上ある設定をそれぞれの名前がついたメソッド名で扱うことができています。ただ、デメリットとして Setting.thumbnails_enabled というコードを見かけて「中で何をやっているんだろう」と思って thumbnails_enabled というキーワードでコードを検索してもメソッドが出てこないという問題があるので、メタプログラミングのやり過ぎには注意が必要です。また、使い方によっては意図せぬ脆弱性が生まれてしまうこともあります。

SettingクラスではSetting.load_available_settingsというメソッドからSetting.define_settingを呼び出しています。前述したconfig/settings.ymlに記載された設定単位でループしてSetting.define_settingを呼び出しているので、うっかりconfig/settings.ymlの中身を消したらメソッドの自動生成がうまく働かなくなります。

app/models/setting.rb

  def self.load_available_settings
    YAML::load(File.open("#{Rails.root}/config/settings.yml")).each do |name, options|
      define_setting name, options
    end
  end

Setting.define_plugin_setting

Setting.define_settingに似たSetting.define_plugin_settingというメソッドもあります。これは、プラグインの設定もRedmine本体の設定と同じように自動生成したメソッドで呼び出せるようにするためのもので、内部でSetting.define_settingを呼び出しています。例えば redmine_sampleというプラグインがある場合は以下のメソッドができます。

app/models/setting.rb

  def self.define_plugin_setting(plugin)
    if plugin.settings
      name = "plugin_#{plugin.id}"
      define_setting name, {'default' => plugin.settings[:default], 'serialized' => true}
    end
  end

app/models/setting.rb

  def self.load_plugin_settings
    Redmine::Plugin.all.each do |plugin|
      define_plugin_setting(plugin)
    end
  end

serialized

Settingクラスでは、次のように本来ActiveRecord::Baseを継承している時点で存在するはずのSetting#value(ゲッターメソッド)、Setting#value=(セッターメソッド)をオーバーライドしています。

app/models/setting.rb

  def value
    v = read_attribute(:value)
    # Unserialize serialized settings
    if available_settings[name]['serialized'] && v.is_a?(String)
      v = YAML.safe_load(v, permitted_classes: Rails.configuration.active_record.yaml_column_permitted_classes)
      v = force_utf8_strings(v)
    end
    v = v.to_sym if available_settings[name]['format'] == 'symbol' && !v.blank?
    v
  end

  def value=(v)
    v = v.to_yaml if v && available_settings[name] && available_settings[name]['serialized']
    write_attribute(:value, v.to_s)
  end

Settingのvalueにはテキストだけではなく数値や0/1でBooleanを表現するような値など、さまざまな種類の値が入ります。プラグインの設定などをはじめとして、ハッシュを入れることもあります。ActiveRecordの一つとしてtext型のカラムに対して配列やハッシュ等好きなデータ型のデータを放り込むことができるserializeやstoreという機能がありますが、Redmineではそれらを使わず自作しています。シリアライズしない素直に文字列や数値を持っている設定もあるためそれらと両立するためか、serializeやstoreの機能ができるより前に書かれたか(15年前)のどちらかかなと思います。

キャッシュ

Settingのデータへのアクセスを減らすため、値をキャッシュする仕組みがあります。これも15年前に実装された仕組みでした。Railsのキャッシュに関してはあまり詳しくないのですが、今新しく書くなら別の実装方法がありそうな気もします。

設定のvalueを取得しようとしたとき、次のように@cached_settings[name]に値があるかを先に見て、なければDBから値を取得します。

app/models/setting.rb

  # Returns the value of the setting named name
  def self.[](name)
    @cached_settings[name] ||= find_or_default(name).value
  end

Setting.check_cacheメソッドがApplicationControllerのbefore_action(つまりリクエストのたびに)呼び出され、@cached_cleared_onよりも新しい設定があればキャッシュをクリアするという仕組みになっているようです。

app/models/setting.rb

  # Checks if settings have changed since the values were read
  # and clears the cache hash if it's the case
  # Called once per request
  def self.check_cache
    settings_updated_on = Setting.maximum(:updated_on)
    if settings_updated_on && @cached_cleared_on <= settings_updated_on
      clear_cache
    end
  end

【スタッフ募集中】
弊社ではAWSを活用したソリューションの企画・設計・構築・運用や、Ruby on Rails・JavaScriptフレームワークなどを使用したアプリケーション開発を行うスタッフを募集しています。詳細はこちら
弊社での勤務に関心をお持ちの方は、知り合いの弊社社員・関係者を通じてご連絡ください。


こちらの記事もオススメです!
Redmine 5.0 の新機能で仕事の効率が上がりました
Redmine 5.0で追加された「チケットのオートウォッチ」「メンション」などの機能が便利です。
エディタをAtomからVisual Studio Codeに変えました
エディタをAtomからVisual Studio Codeに変更。便利に使うために行った設定。
AWS CLI の活用例の紹介
最近よく使う AWS CLI の便利なコマンドを紹介します。
スマートフォンのアプリのデータをadbコマンドを使用して移行しました
スマホを買い替え。Googleが公開しているコマンドラインツール(adbコマンド)でスマホのデータを移行。
社内セキュリティ教育の教材として調査報告書(つるぎ町立半田病院様公開文書)を採用してみました
半田病院様の調査報告書を教材にしてISMS推進チームの教育を実施しました。
ファーエンドテクノロジーからのお知らせ(2024/04/24更新)
入門Redmine 第6版 出版記念企画セミナー「Redmineのアクセス制御」【2024/5/30開催】
入門Redmine 第6版(2024年3月23日発売)の書籍から「Redmineのアクセス制御」について解説します。
My Redmine 初回ご契約で「入門Redmine 第6版」プレゼントのお知らせ
Redmineのクラウドサービス「My Redmine」を初めてご契約いただいたお客様にRedmine解説書「入門Redmine 第6版」を進呈いたします。
2024年度ブランドパートナーに島根県在住のモデル ユイさんを継続起用
ユイさん(モデルスタジオミューズ所属)をファーエンドテクノロジーの2024年度ブランドパートナーとして継続して起用します。
My Redmine スタンダードプランおよびAdminサポートデスクプランの料金改定のお知らせ【2024年4月ご利用分より】
2024年4月ご利用分より、My Redmine スタンダードプラン(民間企業・個人向け及び官公庁向け)とAdminサポートデスクプランの料金を改定いたします。
Redmineの最新情報をメールでお知らせする「Redmine News」配信中
新バージョンやセキュリティ修正のリリース情報、そのほか最新情報を迅速にお届け