今週のブログ担当は石川です。
RedmineはRuby on Railsでできているオープンソースソフトウェアです。コードが公開されているので誰でもコードを読むことができます。RedmineのSettingクラスの実装があまり見ない感じで面白かったので紹介します。
2022/10/11時点で最新のコード(r21901)についての記事です。本記事はコードを読んで挙動や意図などを推測したものなので、知識不足などのため正確でない内容が含まれているかもしれません。
SettingクラスにはRedmineで管理者のみが操作できる設定画面に関するコードが書かれています。一般ユーザーではあまり見ることの無い部分ですが、多数のRedmine全体に対する設定を行うことができます。設定の種類は100種類以上あります。
テーブルはこのようにシンプルな構造で、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'の値を持つデータになります。
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を読み込んでいます。初期はSettingレコードが存在しない状態ですが、設定画面で一度でも保存ボタンを押すと表示している設定タブ内の設定をデータとして保存します。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はあくまでも初期値であり、画面上で保存ボタンを押していて既にその設定のデータができている場合はそちらが優先されるため、後からファイルを書き換えても思ったように設定が反映されないことの方が多いと思います」という挙動になるのはこのコードによるものです。
# 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クラスには、動的にメソッドを生成するコードが入っています。メタプログラミングや黒魔術と呼ばれるような書き方ですね。
# 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つのメソッドが生成されます。
Setting.thumbnails_enabled
Setting.thumbnails_enabled?
Setting.thumbnails_enabled=
class_eval: https://docs.ruby-lang.org/ja/latest/method/Module/i/class_eval.html
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の中身を消したらメソッドの自動生成がうまく働かなくなります。
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_settingに似たSetting.define_plugin_settingというメソッドもあります。これは、プラグインの設定もRedmine本体の設定と同じように自動生成したメソッドで呼び出せるようにするためのもので、内部でSetting.define_settingを呼び出しています。例えば redmine_sampleというプラグインがある場合は以下のメソッドができます。
Setting.plugin_redmine_sample
Setting.plugin_redmine_sample?
Setting.plugin_redmine_sample=
def self.define_plugin_setting(plugin) if plugin.settings name = "plugin_#{plugin.id}" define_setting name, {'default' => plugin.settings[:default], 'serialized' => true} end end
def self.load_plugin_settings Redmine::Plugin.all.each do |plugin| define_plugin_setting(plugin) end end
Settingクラスでは、次のように本来ActiveRecord::Baseを継承している時点で存在するはずのSetting#value(ゲッターメソッド)、Setting#value=(セッターメソッド)をオーバーライドしています。
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から値を取得します。
# 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よりも新しい設定があればキャッシュをクリアするという仕組みになっているようです。
# 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で追加された「チケットのオートウォッチ」「メンション」などの機能が便利です。 |
![]() |
エディタをAtomからVisual Studio Codeに変更。便利に使うために行った設定。 |
![]() |
最近よく使う AWS CLI の便利なコマンドを紹介します。 |
![]() |
スマホを買い替え。Googleが公開しているコマンドラインツール(adbコマンド)でスマホのデータを移行。 |
![]() |
半田病院様の調査報告書を教材にしてISMS推進チームの教育を実施しました。 |
![]() |
社員研修に伴うサポート体制変更・休業のお知らせ(5/20〜23) 社員研修に伴い、5月20日〜23日はサポート体制の変更および休業とさせていただきます。 |
![]() |
オープンソースカンファレンス2025 Nagoyaに弊社代表の前田が登壇(ブース出展あり) オープンソースカンファレンス(OSC)2025 Nagoyaに弊社代表の前田が登壇。『Redmineの意外と知らない便利な機能(Redmine 6.0 対応版)』をテーマに発表します。 |
![]() |
エンタープライズプラン向け「優先サポート」を開始 My Redmineでは、エンタープライズプランをご契約のお客様向けにサポート対応を優先的に行う「優先サポート(プライオリティサポート)」を開始いたしました。 |
![]() |
プロジェクト管理ツール「RedMica」バージョン 3.1.0をリリース Redmine互換のオープンソースソフトウェア ファーエンドテクノロジー株式会社は、2024年11月19日(日本時間)、Redmine互換のプロジェクト管理ソフトウェア「RedMica 3.1.0」をリリースしました。 |
![]() |
Redmineの最新情報をメールでお知らせする「Redmine News」配信中 新バージョンやセキュリティ修正のリリース情報、そのほか最新情報を迅速にお届け |