62
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Ruby on RailsAdvent Calendar 2015

Day 7

Redmine プラグイン開発に学ぶパッチング作法

Last updated at Posted at 2015-12-06

この記事は2015年に書かれたもので使用するメソッドや各種ソフトウェアのバージョンがとても古くなっているのでご注意ください。

イントロダクション

記事の目的

Ruby on Rails によるモンキーパッチの作法について記録します。また、Redmine のサンプルプラグインを開発しながら実践します。

Redmine とは?

Ruby on Rails で書かれたオープンソースのプロジェクト管理ソフトウェアです。
http://redmine.jp
https://github.com/redmine/redmine

モンキーパッチとは?

オリジナルのソースコード(今回は Redmine のコア)を変更することなく、ソフトウェアの振る舞いを変更する方法のことです。

環境

  • Redmine 3.1.2
  • Ruby on Rails 4.2.5
  • Ruby 2.2.3

他のパッチと平和に共存するための3つの作法

Redmine のプラグイン開発に限らず、パッチングの作法として意識すべきことが3つあります。

1. コードの上書きは最小限に

Ruby の素晴らしさは、Redmine 本体のソースコード(以下"コア"と呼びます) に存在するクラスをオープンし、メソッドをオーバーライドして好き勝手に振る舞いを変更できることです。また Redmine は、プラグインに置かれている View ファイルを優先的に扱うので、ただ erb ファイルを配置しておくだけで、View の内容を書き換えることができます。

ただし、このような上書きを行ってしまうと、別のプラグインも同じファイルやメソッドを上書きしていた場合に困ります。後からロードされたもの勝ちになってしまうためです。コアのコードをガリガリ書き変えていけるのは Ruby の魅力ですが、alias_method_chain などといった、パッチングに適した機構が用意されているのも Ruby の魅力です。また、View に関しても上書きするより、Javascript で DOM を動的に書き換えていった方が、手間はかかりますが上書きは回避することができます。後ほどサンプルプラグインを開発しながら、具体例を紹介します。
※ 現在 alias_method_chain は非推奨となっています。

2. データベーススキーマへの影響は最小限に

プラグイン側でマイグレーションファイルを書いて、データベーススキーマを変更することができますが、できることなら既存のテーブルにカラムを追加することは避けた方が良いでしょう。例えば projects テーブルに何かしらのカラムを追加したくなったとしても、projects テーブルを拡張するのではなく、別のテーブルを作成して belongs_to :project した方が、より安全です。こちらも後ほど具体例を紹介します。

3. 名前空間の汚染は最小限に

例えば上記のように、データベースに新たなテーブルを追加する場合や、新たなモデルクラスといったグローバルスコープな識別子は、別のプラグインと衝突しないように注意が必要です。最も安易な衝突回避策は、識別子にプラグイン名のプレフィックスを設けることです。ただ、あまり美しくないのでケースバイケースということにしまして、今回のサンプルプラグインの開発では、そこまでの考慮はしませんが、衝突の危険性は孕んでいることだけ意識しておく必要があります。

サンプルプラグイン開発

完成イメージ

上記3点のパッチング作法の実践として、早速 Redmine のサンプルプラグインを開発してみます。とてもシンプルなプラグインですが、手順はそこそこ多いので、パッチング作法の本質的でない説明が多くなります。とはいえ、プラグイン開発について真面目に学ぶには説明足らずな中途半端な記事であることをご容赦ください。

これから開発するサンプルプラグインのソースコードは、下記で公開しています。
https://github.com/ogihara-ryo/show_important_projects

さて今回は、プロジェクト一覧画面におけるプロジェクトツリーの上に、任意の重要プロジェクトを表示する拡張を行います。まあ、常に上位表示しておきたいプロジェクトを設定するためのプラグイン、というイメージです。
完成図.png

任意のプロジェクトは、プロジェクトの設定画面へチェックボックスを追加して、設定することにします。
重要プロジェクト設定.png

Redmine 本体とプラグインスケルトンの準備

Redmine 本体を clone する

では、さっそく始めましょう。Redmine のリポジトリは下記です。
https://github.com/redmine/redmine

shell
git clone https://github.com/redmine/redmine.git

bundle install

shell
bundle

secret_key_base の設定

まず、config/secrets.yml を作成します。

shell
cd redmine
touch config/secrets.yml

下記コマンドを入力し、secret_key を出力してもらいます。

shell
bundle exec rake secret
2d32ad430fd28c65781315a156211c031c8a72883bd966...

今出力された長いランダムな文字列を、config/secrets.yml に設定します。

config/secrets.yml
development:
  secret_key_base: 2d32ad430fd28c65781315a156211c031c8a72883bd966...

データベースの設定ファイル作成

# config/dababase.yml はご使用の環境に合わせて設定してください。

shell
cp config/database.yml.example config/database.yml

bundle install

shell
bundle

データベースの作成

shell
bundle exec rake db:create
bundle exec rake db:migrate

この辺で rails s してサーバーの起動確認をしておくと良いでしょう。

プラグインスケルトン作成

今回は show_important_projects という名前のプラグインを作成します。

shell
bundle exec rails g redmine_plugin show_important_projects
cd show_important_projects

重要プロジェクトモデルの追加

さて、重要なプロジェクトを上位表示するプラグインを作成しますので、重要かどうかを判定する論理型のカラムを作成する必要があります。単純に考えれば、projects テーブルに add_column するマイグレーションを書いて。。と考えるところですが、

2.データベーススキーマへの影響は最小限に

の法則に従うことにしましょう。今回は important_projects テーブルを新たに作成し、projects テーブルと has_one / belongs_to の関連を設定し、そのテーブルに important というカラムを設けることにします。

モデル/マイグレーションファイルの作成

shell
bundle exec rails g redmine_plugin_model show_important_projects ImportantProject project:belongs_to important:boolean

生成されたマイグレーションクラス(編集不要)

db/migrate/001_create_important_projects.rb
class CreateImportantProjects < ActiveRecord::Migration
  def change
    create_table :important_projects do |t|
      t.belongs_to :project, index: true, foreign_key: true
      t.boolean :important, default: false, null: false
    end
    add_index :important_projects, :project_id
  end
end

migrate コマンド実行

では、このまま migrate コマンドを実行します。通常の Ruby on Rails アプリケーションの migrate コマンドとは少し異なるので注意が必要です。

shell
bundle exec rake redmine:plugins:migrate NAME=show_important_projects

コアのモデルを拡張して関連を設定

コアの Project モデルと、今作成した ImportantProject モデルを has_one / belongs_to の関連で設定します。

ImportantProject モデル

まずは、ImportantProject モデルに設定しましょう。いつも通りですね。

app/models/important_project.rb
class ImportantProject < ActiveRecord::Base
  unloadable
  belongs_to :project
end

Project モデル

さて、Project モデルは、コアで実装されているモデルなので、ここでようやく Ruby on Rails のパッチングについてお話することができます。とはいえ、業務経験4ヶ月程度のクソザコがあれこれ説明するよりは、読んだ方が早いコードに仕上がっていると思うので、まずはご覧頂くことにします。

lib/show_important_projects/project_patch.rb
require_dependency 'project'

module ShowImportantProjects
  module ProjectPatch
    extend ActiveSupport::Concern

    included do
      unloadable
      has_one :important_project, dependent: :destroy
    end

    def important?
      important_project.try(:important?)
    end
  end
end

ActionDispatch::Reloader.to_prepare do
  unless Project.included_modules.include?(ShowImportantProjects::ProjectPatch)
    Project.send(:include, ShowImportantProjects::ProjectPatch)
  end
end

末尾の5行で、コアの Project クラスに対して、ShowImportantProjects::ProjectPatchinclude します。このとき

3.名前空間の汚染は最小限に

の法則に従って、パッチモジュールを ShowImportantProjects でラップしておきます。

ShowImportantProjects::ProjectPatch では、先ほど作成した important_projects テーブルとの関連を設定しています。この関連を利用して、important? と聞けば、自分が重要かどうか返してくれるように拡張してみました。

このファイルは init.rb から require_dependency します。これからもパッチファイルは増えていくので、lib/show_important_projects ディレクトリ配下の全 Ruby ファイルを require_dependency するようにしておきます。

init.rb
+ Dir[File.expand_path('../lib/show_important_projects', __FILE__) << '/*.rb'].each do |file|
+   require_dependency file
+ end


  Redmine::Plugin.register :show_important_projects do
    name 'Show Important Projects plugin'
    author 'Author name'
    description 'This is a plugin for Redmine'
    version '0.0.1'
    url 'http://example.com/path/to/plugin'
    author_url 'http://example.com/about'
  end

これで、コアの Project モデルクラスにパッチをあてることができました。rails cProject モデルのインスタンスから important? できるか確認してみると良いでしょう。ちなみに Project モデルのインスタンスをコンソールから作るのは結構しんどいので、ブラウザーから一旦作成しておいた方が良いです。

フックが用意されている View の拡張

さて、これからプロジェクト設定画面(新規作成画面含)に重要プロジェクトか否かを設定するチェックボックスを設置します。拡張対象の View ファイルは、コア側の app/views/projects/_form.html.erb です。

各種プロジェクト設定項目の下に、チェックボックスを1つ追加するのは、とても簡単です。各種プロジェクト設定項目の下に、フックが用意されているためです。<div class="box tabular">の一番下にある、こいつです。

app/views/projects/_form.html.erb
<%= call_hook(:view_projects_form, :project => @project, :form => f) %>

これは、パッチングが楽に済む例です。このフックを利用して、View を拡張します。

フック箇所に部分テンプレートを差し込む準備

Redmine の View フックを利用して部分テンプレートを差し込むのは非常に簡単です。このように、:view_projects_form のフックに対して、差し込む部分テンプレートを指定するだけです。

lib/show_important_projects/hooks.rb
module ShowImportantProjects
  class Hooks < Redmine::Hook::ViewListener
    render_on :view_projects_form, partial: 'projects/hook_view_projects_form'
  end
end

重要プロジェクト設定保存機能の実装

コアに定義されているテーブルの関連テーブルを操作したいときは、:accepts_nested_attributes_for を利用すると幸せになれます。
http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html

lib/show_important_projects/project_patch.rb
    included do
      unloadable
      has_one :important_project, dependent: :destroy
+     safe_attributes :important_project_attributes
+     accepts_nested_attributes_for :important_project
    end
app/models/important_project.rb
  class ImportantProject < ActiveRecord::Base
    unloadable
    belongs_to :project
+   attr_accessible :important
  end

safe_attributes, attr_accessibleStrong Parameters に対応していない Redmine のためのおまじないです。最後に、先ほどのフックに差し込む部分テンプレートを書いていきます。

app/views/projects/_hook_view_projects_form.html.erb
<p>
  <label for='important'>重要プロジェクト</label>
  <%# check_box_tag は未チェック時にパラメーターを送らないため、hidden_field_tag を使用 %>
  <%= hidden_field_tag 'project[important_project_attributes][important]', false %>
  <%= check_box_tag 'project[important_project_attributes][important]', true, @project.important?, id: 'important' %>
</p>

フックが用意されていない View の拡張

さて、ではプロジェクト一覧画面(projects/index)の上部に、重要プロジェクトを表示するための拡張を行います。ここで、1つ問題があります。なんと、コアの app/views/projects/index.html.erb 、及びそこから render される部分テンプレートには、フックが用意されていません。

全画面共通の :view_layouts_base_content というフックは用意されているので、context[:controller].controller_name == 'projects' && context[:controller].action_name == 'index' の判定を行ってプロジェクト一覧画面のみを拡張させることも可能ですが、全画面共通のフックに、projects/index 専用の処理を書くのは気持ちが悪いものです。

差し込めそうな場所を探す

途方に暮れながら app/views/projects/index.html.erb を読んでいると、render_project_hierarchy という、何やらいい感じのヘルパーを見つけました。プロジェクト一覧を階層的に render するためのヘルパーのようです。

app/helpers/projects_helper.rb
  # Renders the projects index
  def render_project_hierarchy(projects)
    render_project_nested_lists(projects) do |project|
      s = link_to_project(project, {}, :class => "#{project.css_classes} #{User.current.member_of?(project) ? 'my-project' : nil}")
      if project.description.present?
        s << content_tag('div', textilizable(project.short_description, :project => project), :class => 'wiki description')
      end
      s
    end
  end

コアの膨大なコードから、パッチングに適した箇所を探し出すのは茨の道です。「ここに書くしかないのかな。。」といった妥協の元、選ぶ羽目になることも少なくありません。まあ、今回はサンプルプラグインなので気にせず話を進めます。

alias_method_chain によるメソッドの拡張

さて、この app/helpers/projects_helper.rbrender_project_hierarchy メソッドを拡張するわけですが、

  1. コードの上書きは最小限に

の法則に従い、オーバーライドは避けることにします。そこで役に立つのが、ActiveSupportalias_method_chain です。
http://apidock.com/rails/ActiveSupport/CoreExtensions/Module/alias_method_chain

さっそく使ってみましょう。Project モデルクラスと同様に、ProjectHelper モジュールの継承ツリーに自作のパッチを加えます。

lib/show_important_projects/projects_helper_patch.rb
require_dependency 'projects_helper'

module ShowImportantProjects
  module ProjectsHelperPatch
    extend ActiveSupport::Concern

    included do
      alias_method_chain :render_project_hierarchy, :show_important_projects
    end

    def render_project_hierarchy_with_show_important_projects(projects)
      render_project_hierarchy_without_show_important_projects
    end
  end
end

ActionDispatch::Reloader.to_prepare do
  unless ProjectsHelper.included_modules.include?(ShowImportantProjects::ProjectsHelperPatch)
    ProjectsHelper.send(:include, ShowImportantProjects::ProjectsHelperPatch)
  end
end

alias_method_chain は、第一引数_with_第二引数 といった名前でメソッドのエイリアスを作成しつつ、オリジナルのメソッドを 第一引数_without_第二引数 に退避します。render_project_hierarchy_with_show_important_projects と少し長い名前になってしまいましたが、これらのメソッド名が衝突したら本末転倒なので、目を瞑っておきます。

render_project_hierarchy は DOM を返すメソッドなので、オリジナルの render_project_hierarchy_without_show_important_projects を呼ぶ前に、重要プロジェクト一覧の DOM を作って詰めてしまえば OK です。

Project モデルに重要プロジェクトのみを返す scope を追加して

lib/show_important_projects/project_patch.rb
    included do
      unloadable
      has_one :important_project, dependent: :destroy
      safe_attributes :important_project_attributes
      accepts_nested_attributes_for :important_project

+     scope :importants, -> { joins(:important_project).where(important_projects: { important: true }) }
    end

コア側の app/helpers/projects_helper.rbrender_project_hierarchy メソッドや、app/helpers/application_helpers.rbrender_project_nested_lists メソッドを参考にしつつ、よいしょよいしょと詰めていきます。最終的に ProjectsHelperPatch はこんな感じになりました。

lib/show_important_projects/projects_helper_patch.rb
require_dependency 'projects_helper'

module ShowImportantProjects
  module ProjectsHelperPatch
    extend ActiveSupport::Concern

    included do
      alias_method_chain :render_project_hierarchy, :show_important_projects
    end

    def render_project_hierarchy_with_show_important_projects(projects)
      s = render_important_projects
      s << render_project_hierarchy_without_show_important_projects(projects)
      s.html_safe
    end

    private

    def render_important_projects
      s = "<h2>重要プロジェクト</h3><ul id='important_projects' class='projects root'>"
      Project.importants.each do |project|
        s << "<li class='root'><div class='root'>"
        s << link_to_project(project, {}, class: "#{project.css_classes} #{User.current.member_of?(project) ? 'my-project' : nil}")
        s << '</div></li>'
      end
      s << '</ul>'
      s << "<hr id='break_important_projects' style='margin: 24px 0;' />"
    end
  end
end

ActionDispatch::Reloader.to_prepare do
  unless ProjectsHelper.included_modules.include?(ShowImportantProjects::ProjectsHelperPatch)
    ProjectsHelper.send(:include, ShowImportantProjects::ProjectsHelperPatch)
  end
end

これで表示してみると、なかなかいい感じなのですが、見出しが2つ並んでいて文書構造が不自然です。プロジェクト と書いた見出しを、下に移動させてしまうのが良い気がしますね。
見出しを移動する.png

しかし、この見出しは render_project_hierarchy で作っているわけではありません。コアの app/views/projects/index.html.erb に書かれているのです。簡潔に済ませるなら、app/views/projects/index.html.erb を上書きして見出しを削除し、render_project_hierarchy で適切な場所に差し込めば OK です。ただしパッチングの良い作法は

  1. コードの上書きは最小限に

です。ただでさえ拡張されやすそうな app/views/projects/index.html.erb を、こんなつまらないことで上書きしたくはありません。ここで新たな View 拡張方法を紹介します。

Javascript による View の拡張

フロント側で Javascript(jQuery) によって DOM を書き換える方法を試してみます。javascript_include_tag を該当の View に埋め込むことで、該当の画面に Javascript のコードを埋め込みます。javascript_include_tag は、該当 View のフックにて埋め込みますが、今苦労している通り、拡張している画面にフックは存在しないため、先ほどの render_project_hierarchy を利用します。

lib/show_important_projects/projects_helper_patch.rb
    def render_project_hierarchy_with_show_important_projects(projects)
      s = render_important_projects
      s << render_project_hierarchy_without_show_important_projects(projects)
+     s << javascript_include_tag('move_head2_tag', plugin: 'show_important_projects')
      s.html_safe
    end
end

これで Javascript でできることなら、どんな拡張でも思いのままになりました。今回は div#content 内で一番始めに登場する h2 要素を、先ほど追加した重要プロジェクトと全プロジェクトを区切る hr 要素の真下に移動します。

assets/javascripts/move_head2_tag.js
(function () {
  $('#content > h2:first').insertAfter($('hr#break_important_projects'));
}());

このようなコードを書く際にも、最も共存するパッチへの影響が少なく済むように意識する必要があります。例えば今回のサンプルプラグインでは、hr 要素に id を付加して、insertAfter に渡す要素を確実なものにしています。

ただし今回の場合、共存することになったプラグインに div#content へ別の h2 要素を差し込まれてしまったら、意図した動きになりません。そんなリスクを常に抱えつつも、できる限りリスクを減らせるように、気を使うことが重要です。

さて、長々とした記事になってしまいましたが、これでサンプルプラグインは完成です。
完成図.png

まとめ

今回はパッチングに必要な3つの作法について、記録しました。

  1. コードの上書きは最小限に
  2. データベーススキーマへの影響は最小限に
  3. 名前空間の汚染は最小限に

私はまだ Ruby on Rails を始めて間もないため経験はありませんが、Redmine のプラグイン開発だけでなく、他のプラグイン機構を提供している Ruby on Rails アプリケーションのプラグイン開発や Gem の拡張などで、これらの作法が役に立つのではないかと思います。

以上です、ありがとうございました。

62
73
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
62
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?