Categories
open all | close allTags
ドキュメント | 国際化 | スキンエンジン | Subversion | 認証 | タグ | パソコン | JustPosted | デュアル・コア | Flash | アクセス制御 | Aptana | Migration | RESTful | CSRF | テスト | モデル | フォーム | 名称 | rakeSearch
タグとカテゴリーのサポートについて
Foodyn CMSではツリー型のマルチカテゴリーとタグを標準でサポートします。機能的にはNucleusのMultipleCategoriesとTagEXと同等以上のものになるでしょう。詳しくは後日説明しますが,大部分は2年前にNucleusにこれらの機能を入れようとしたときのものをベースにしています(コードそのものはもう忘れたので別物ですが,考え方など)。
カテゴリー・テーブルを拡張してツリー型の管理およびタグの管理ができるようにします。ツリー管理ではNested Setと呼ばれる,SQL1回の呼び出しで子ツリーを全部取り出せる方法を取ります(説明はこちらの方が分かりやすいかも)。
ツリー構造はブログごとに持ちます。また,タグもブログごとに持ちます。これはタグからカテゴリーへの変換やその逆が楽にできるようにするためです。
タグの場合はブログ横断で検索したいケースもあると思うので,複数のタグが共通の「親タグ」(あるいは「別名タグ」)を参照するようにします。例えばブログ1に「Rails」というタグがあって,IDが10だとします。ブログ2にも「Rails」というタグがあってIDが20だとします。このときにaliasidというフィールドはどちらも10になります(別にどちらも20にしても構いません。共通であればいいだけなので)。このaliasidのフィールドを使うとブログを串刺しにしたタグ検索が可能です。
アイテムとタグ,カテゴリーを紐付けるために新しいテーブルを作ります。ここにはアイテムのIDとタグ・カテゴリーのIDがカテゴリーやタグの数だけ入ります。このテーブル上ではタグとカテゴリーの区別はなく,あくまでもカテゴリー・テーブルにおける違いだけになります。
RailsのActiveRecordにおいては,このテーブルを介してアイテムと,タグ・カテゴリーが多対多のリレーションを持つことになります。このとき「has and belongs to many(HABTM)」あるいは「has_many :through」という二つの方法があり,前者では中間テーブルはRailsが管理し,ActiveRecordで直接いじることはできません。ここでは自由度が高い後者の方法を使っています。
思ったより大変でない
前の記事で書いたTagEXデータのコンバートについてですが,どちらにしてもモデルで,そのタグがそのブログで新規かどうかのチェックなどをしないといけないので,コンバート自体についてはあまり深く考えなくてもそのメソッドを呼び出すだけでできそうです。裏方仕事は多少ありますが,それは当然のこと。
MultipleCategoriesのコンバート部分も基本的にはできたはず。両方できてからじゃないとチェックするのが面倒なのでまだ動かしていませんが。
URLによるプラグインへのアクセス
プラグインの機能として,外部から呼び出すこと(Nucleusで言えばActionのところ)が必要なわけですが,一応RESTfulを意識して実装しておきました。以前から気になっていたresource_hacksプラグインを使うことでmap.resourcesに対して名前付きのアクセスができるようにしています。将来はほかの部分もこれを使って書きなおすかもしれません。というわけでルート定義は次のような簡単なもの
map.resources :plugins, :member_path => '/plugin/:permalink'
plugins_controllerもいたってシンプル
class PluginsController < ApplicationController
before_filter :find_plugin
delegate :index, :show, :new, :edit, :create, :update, :destroy, :to=>"@plugin.main"
private
def find_plugin
if !(@plugin = Plugins.find_by_plugin_name(params[:permalink]))
return false
else
@plugin.main.params = params
end
end
enddelegateで委譲してしまっているので,ほとんどやることがありません。唯一パラメータを受け渡せるようにしておかないといけないので,FoodynPlugin::Mainクラスにparamsインスタンス変数を加えています。
attr_accessor :params
ちょっと注意しないといけないのはプラグインのメインの中ではidex, show, new, edit, create, update, destroyメソッドは外部から簡単に呼ばれてしまうことです。セキュリティホールになる恐れがあります。
プラグイン関係は,実装はまだまだやることがありますが,基本的な仕組みは全部できたので,マルチカテゴリーとタグのサポートを次に実装しようかと思います。
動かなかった理由がよく分かりませんが
今日,もう一回イベントのところを動かしてみたら何の問題もなく動いていました。この前動かなかったのはなぜ?
というわけでeventのところはNucleusよりももうちょっと簡単に書けます。プラグインではevent~~というメソッドを用意するだけ。イベント一覧を返すメソッドがなくてもメソッド名を調べて登録します。呼び出し側もイベント名でnotifyするだけ。
プラグイン関係はあとactionが実行できるようになれば,一応一通り仕組みはできたことになります。
イベントの処理に取り掛かる
プラグイン関連の処理でイベントの部分を書いています。アクティブなプラグインからイベント情報を収集してデータベースに保存するところは完成。プラグインを呼び出すところまではできているのですが,その後がなぜか動かない。デバッガが使えないと不便です。雰囲気的には変数名が違っているといった単純なものではないかと思っているのですが。美しくない解決法
いろいろ調べるうちに,FoodynPlugin::Baseというクラス・オブジェクトが二つ存在していることが分かってきました。「なんてこった」って感じです。クラスはシングルトンじゃないんでしょうか。愚痴りたい気持ちはやまやまですが,とにかく動くようにすることが先決なので,なんとか解決法を考えました。
def self.list
@pluginlist ||= Array.new
if @pluginlist.size == 0
@pluginlist = Class.constants.select do |c|
cclass = c.constantize
if (cclass.is_a? Module) && (cclass.constants.include? 'Base')
klass = (c+'::Base').constantize
klass.ancestors.any? {|anc| anc.name == 'FoodynPlugin::Base'}
end
end
end
@pluginlist
end
def self.plugin_class plugin_name
if (pl = list.find {|p| p.downcase == plugin_name.downcase})
return (pl + '::Main').constantize
end
end
プラグイン・クラスの一覧を取るアルゴリズムですが,まずはClassレベルにおける定数を調べ,そこでModuleに属するものを選び,さらに,Module内の定数で「Base」を含んでいるものを選びます。この中から,Base付きのクラスの先祖のクラスを調べ,そこにFoodynPlugin::Baseがあるかどうかで判断しています。本来ここはクラスレベルで判定できるはずですが,上記の理由によって,わざわざ文字列に変換して比較しています。
美しくはありませんが,ともかくこれでスキン変数が動くようになりました。なんだかどっと疲れました。
現象は分かったけど,どうしたものか
プラグインのスキン変数がうまく動いていなかった件,前進しましたが解決にいたらず。ちょっと困惑しています。プラグインのベースのクラスFoodynPlugin::Baseのクラスメソッドでは次のようなメソッドを用意しています。
def self.inherited subclass
@@pluginlist ||= Array.new
@@pluginlist.push subclass
end
def self.list
@@pluginlist ||= Array.new
end
def self.plugin_class plugin_name
if (pl = list.find {|p| p.pname.downcase == plugin_name.downcase})
return (pl.pname + '::Main').constantize
end
end
def self.pname
self.name =~ /^(.*)::/
return $1 # default name
end
ここで,inheritedというのは,そのクラスを親とする子クラスが定義されるときに自動的に呼ばれるメソッド。これを使って@@pluginlistというクラス変数にプラグインのベース・クラスの配列を入れています。スキンをパースするときに,エンジンが持っているparse_~~というルーチンとマッチしないと,FoodynPlugin::Base.plugin_classメソッドを使って,そのプラグインが存在しているかどうかを調べます(今思ったのですが,このメソッドは割と呼び出し回数が多いので,この中で名前を調べているのはだいぶ無駄な感じがします。名前自体を持たせる方がいいかも)。プラグインが存在すると,そのメイン部分のクラスを返します(ここもインスタンスを返すようにしたほうがいいかも)。そうするとパーサ側でこのプラグインのインスタンスに対してdo_skin_varメソッドを発行するわけです。
これで動くのですが,よく分からないのは,ページをリロードすると@@pluginlistがなくなってしまうこと。ですから1回目はスキン変数を表示しますが,リロードすると表示しません。クラスはロードしたままだけど,変数は消してしまうということでしょうか。そうだとするとinheritedに頼ることはできないので,このロジック自体考え直す必要がありそうです。ちょっといやらしい実装です。
プラグインの仕様/実装案メモ(4/23更新)
・Railsのプラグインとしてインストール可能にするSubversionのレポジトリ利用など,Railsの機能が利用できるから
・インストール後にFoodyn CMSの管理画面で利用のオンオフをする(デフォルトオフ)
プラグインはFoodynPlugin::Baseクラスの子クラスとして作りinit.rbでrequireしておく。このクラスはスタブであって,本体のコードは入らない。管理画面ではクラス一覧を獲得してそれを表示する。一覧の中でプラグイン・モデル(後述)に登録されていないものがあったら,自動的に登録する。
プラグインの本体はFoodynPlugin::Mainクラスの子クラスとして記述する。
管理画面で「オン」すると,インストールを実行する。具体的にはイベントの登録,スキーマの更新(プラグイン独自のテーブル,プラグイン・オプション)を行う。
「オフ」にするとイベントは削除。スキーマはそのまま。「アンインストール」はイベントおよびデータを削除。init.rbも削除(ここまでしないと一覧時にまた表示されてしまうから)。
「更新」はプラグインのコードを上書きしてから実行できる。イベントとスキーマ,プラグイン・モデル内のバージョン情報を書き換える。
・~~変数はNucleusと同様do_skin_varなどで実装する
Ruby的にはパーサを拡張するような実装も可能だがスキン・エンジン依存になるため,なるべくやらない。
・イベントはevent_~~などのメソッド実装で登録
管理画面上でプラグインをオンにするときに,そのプラグインのメソッドを舐めて調べる。
・PreItemを使うプラグイン用にPostUpdate系のイベントを用意
アイテム表示のたびにPreItemで走査するのは無駄なので決まりきった変換(例えばWiki系の入力補助など)はアイテム登録/更新時に変換する。変換前のデータを保持するためアイテムの本文用にフィールドを追加。
・プラグイン・オプション
プラグイン・オプションはブログ・オプションであればブログの拡張テーブル,アイテム・オプションであればアイテムの拡張テーブルに保存する。プラグイン本体のオプション用テーブルもある。
・独自管理画面
Admin用のクラスを別に持たせる。Nucleusのようにプラグインに直にリクエストが行くのではなく,どこかで受けてから渡すほうがいいだろう。
・アクション
これも一回システム側で受けてからプラグインを呼び出す方向。トラックバックのようなものはRESTのフォーマット追加でやりたいだろうと思うので,RESTのフォーマット追加やアクション追加ができるような仕組みを考えたい。
・スキーマの機能
プラグイン・オプションとプラグインの独自テーブルは,RailsのMigrationと同様の機能で更新する。
・必要なデータ・モデル
プラグイン管理用のモデルは,プラグイン名,バージョン,スキーマ・バージョン,オンオフ情報を登録
イベント管理用モデルはプラグインIDとイベントの名前を登録
プラグインオプションの説明モデルはプラグインID,オプション名,オプションの種類(ブログ,カテゴリー,…),データ・タイプ,デフォルト値,入力補助用データ
プラグインオプションのデータ・モデルは,元モデルごと(ブログ,カテゴリー,…)にテーブルを用意する。
プラグインはクラスで実装すべきかインスタンスにするべきか
プラグインのスキン変数の実装でちょっとバグっています。簡単に取れそうなのに,案外手間がかかってしまっています。その理由の一つはプラグインの実装をインスタンスでしているのに呼び出し側はクラスを呼んでしまっていたところにありました。
そもそもあるプラグインのインスタンスはシステムに一つしかない必要があります。そのため,いわゆるシングルトンのデザインパターンになります(RubyではSingletonモジュールをクラスに読み込むだけですが)。クラスはもともとシングルトンなので,プラグインをクラスで実装しても構わないということになります。
ところが,実際に書いてみると,クラスでプラグインを実装するとかなり分かりにくくなることが判明しました。具体的には,プラグインのベースクラス(プラグインを実行するかどうかにかかわらず,必ず読み込む部分)では,読み込まれたプラグインの一覧をクラス変数に確保するためにクラスメソッドを用意しています。それとプラグイン本体の部分とがごちゃごちゃになってしまうという分かりにくさがありました。
一方で,プラグインを呼び出すときは,プラグインのクラスに直接メソッドを送った方が話が早いので,ついそう書きたくなってしまうのです。結局プラグインの名前を抽出するメソッドをクラス・メソッドにもインスタンス・メソッドにも置くなど,なんだか汚いコードになってしまっています。少し設計を整理した方がいいのかもしれません。
プラグイン用マイグレーション
新しいパソコンのCPUはAthlon 64 X Dual Core。安パソコンですが,デュアル・コアだけは譲れないと思って選びました。やっぱりこれまでとは全然違います。メモリも4Gバイト積んだので,Aptana Studioを起動する時間など,10倍近く早くなったような体感です。まだデバッグ機能は使っていませんが,これまでのようにデバッグモードに入るだけで何分も待つようなことはなくなるでしょう。やっぱり快適です。で,ようやく移行作業もほぼ落ち着き,コード書きに戻っていますが,このところ取り組んでいるのはプラグイン用のさまざまな機能。まだ完全ではないですが,マイグレーションのところは大分進みました。プラグインではそのプラグイン用のmigrationディレクトリに
class FpHelloworld::CreateOptions < FoodynPlugin::Migration
def self.up
add_string(:str, :desc=>'string to show', :default=>'Hello World')
add_string(:itemstr, :type=>'item', :desc=>'item string', :default=>'goodbye')
create_table :data do |t|
t.string :item_str
t.integer :item_id
end
end
def self.down
remove_string :str
remove_string :itemstr
drop_table :data
end
endといった形で書くだけ。これで適宜フィールドを作ったり,コラムを作ったりテーブルを作ったりします。いろいろなデータタイプに対応するなど,まだいくつか作業はありますが,基本的な仕組みは考えたとおりに動いています。