はじめに
Railsで業務システムっぽいものを開発しているのですが、システムの性質上、それぞれの画面での入力項目が多いこともあり、要望として下書き機能が必須という状況になってました。
当初は下書き機能を提供するgemを導入してサクッと終わらせようと思ったのですが仕様を考えるとちょっとマッチしない部分があったり、あまりメンテされてないような感じだったりと採用するのに不安を感じたので、独自に実装しました。
ある機能の実装方法や便利なgemの情報は色々見つかるのですが、
今回の下書き機能に限定しないと思うのですが、ある仕様を満たすような機能を実装する過程について情報をまとめておくことで、似たようなことに直面した方の参考になるかと思ってまとめることにしました。
過程はどうでもいいのでコードを知りたいっていう方は最後の方にコードを貼ってありますのでそちらをご覧いただければと思います。
今回の仕様について
今回期待されてる下書き機能としては
- 新規作成時に下書きに保存が出来る
- 公開後は下書きに戻したりはできないでOK
- 入力項目で必須項目なものが多いが、下書き保存時には必須項目かどうかに関係なく保存することが可能
という感じ。ちなみにModelの数は20〜30程度ある規模です
覚えてる範囲でどのような考えをしていったのかなぞっていく
まずはじめに、下書き機能についてのgemを調べるのに、draft+Railsみたいなキーワードでいくつか情報にあたっていきました。
導入を考えていたgemをまとめてみた
gem | URL | 考察 |
---|---|---|
kentouzu | https://github.com/seaneshbaugh/kentouzu | 公式のREADME見るとThis gem has only been tested on Rails 3.2という点とThis gem overwrites the ActiveRecord save method. In isolation this is usually harmless. But, in combination with other gems that do the same, unpredictable behavior may result. という点を考えると利用するのがちょっと不安 |
Draftsman | https://github.com/liveeditor/draftsman | 比較的最近開発されていて良さそうな感じだったけど、公式のREADME見ると**The largest risk at this time is functionality that assists with publishing or reverting dependencies through associations (for example, "publishing" a child also publishes its parent if it's a new item)**というのがあり今回のシステムだと、親子関係のモデルに対する操作が多く採用するにはちょっと不安 |
has_draft | https://github.com/rubiety/has_draft | 2年前に更新されてから放置されてるようで、おそらくRails3系がベースになってるので不安 |
PaperTrail | https://github.com/airblade/paper_trail | 開発も安定して行われてる印象で最初はこれを利用しようと考えた。PaperTrailはどうやってActiveRecordのバージョン管理をしているか読んでいたら、Qiitaで利用されてる実績あるので安心な反面、そもそもバージョン管理のような所が主目的で今回のような下書き→公開という一方向な状態管理でOKな場合だとちょっとオーバースペックかと思って一旦保留 |
結局、独自に実装することに決める
PaperTrailについて調べてる前後で以下のエントリを見つけました。
- 1年前くらいに書かれたA Lightweight Way to Handle Different Validation Situations
- 今年の春頃に書かれたBlog article versioning in Ruby on Rails - a post mortem
(だいぶ考えが飛躍しますが)これらを読んだ後にふと、Railsのポリモーフィックアソシエーションの仕組(詳しくはこのブログが個人的にわかりやすいかと)で
class Book < ActiveRecord::Base
has_many :drafts, as: :resource
end
class Draft < ActiveRecord::Base
belongs_to :resource, polymorphic: true
end
のような感じで実装することで仕様を満たしそうな気がしたので、独自に実装することにしました。
少し手を動かしてみてポリモーフィックアソシエーションは今回無理っぽいことに気づく
実装を進めて気づいたのですが今回の仕様で
入力項目で必須項目なものが多いが、下書き保存時には必須項目かどうかに関係なく保存することが可能
となっているので、例えばBookモデルの入力必須項目がまだ埋まってない状況で下書き保存処理に移ると、親になるBook
自体が保存できないので、それに紐づくDraftも保存できないことに気づきました
あと、この段階でbook_paramsに格納されてる入力項目を手軽に、かつ、後々再利用しやすい形でどのようにDBに格納するのかも考えが及んでなかったので、ちょっとここで煮詰まってしまいました。
Railsではないですが以前作ったスマフォアプリのキャッシュ方法を流用することで実現できそうと気づく
自分でもなぜかはわからないのですが、ふと、以前作ったスマフォアプリのキャッシュ方法のことを思い出し、Rails4でserializeしてデータをDBに保存させるを読んでハッシュをDBにシリアライズして保存するというアプローチのことを知りました。
この辺りのアプローチをちょっと応用して、最終的に意図した実装が出来上がりました
おまけ
gemの調査をしている時に、それぞれのソースコードまでは読まなかったのですが、実装を追えて区切りがついたタイミングで気になってたDraftsmanのディレクトリ構造をあらためて眺めたら
lib
├── draftsman
│ ├── config.rb
│ ├── draft.rb
│ ├── frameworks
│ │ ├── cucumber.rb
│ │ ├── rails.rb
│ │ ├── rspec.rb
│ │ └── sinatra.rb
│ ├── model.rb
│ ├── serializers
│ │ ├── json.rb
│ │ └── yaml.rb
│ └── version.rb
├── draftsman.rb
└── generators
└── draftsman
├── install_generator.rb
└── templates
という構成になってることに気づきました。
自分で実装してて、draftとかserializeという単語をよく利用していたので、draft.rbやserializers/json.rbそれぞれのソースがどうなってるのかちょっと読んだら
draft.rbでは
class Draftsman::Draft < ActiveRecord::Base
# Associations
belongs_to :item, :polymorphic => true
end
という記述を見つけ、serializers/json.rbでは
require 'active_support/json'
module Draftsman
module Serializers
module Json
extend self # makes all instance methods become module methods as well
def load(string)
ActiveSupport::JSON.decode string
end
def dump(object)
ActiveSupport::JSON.encode object
end
end
end
end
という感じになっており、自分が考えたアプローチはあながち悪くなかったのかなと思って、Rails経験がまだ浅いけどちょっと自信つきました
下書き機能のソースコード
Modelについて
app/model/draft.rbをこんな感じで実装しました。本当はmodel_nameにしたかったのですが、その単語はRailsがすでに使ってる用語っぽくエラーになったのでこのようなカラム名にしてます
# == Schema Information
#
# Table name: drafts
#
# id :integer not null, primary key
# modelname :string(255)
# publish_status :boolean default(FALSE)
# properties :text(65535)
# created_at :datetime not null
# updated_at :datetime not null
#
class Draft < ActiveRecord::Base
serialize :properties
validates :properties, presence: true
end
Concernモジュールについて
他のModelやControllerがConcernモジュールを読み込むことで、下書き機能の実現を考えたのでこんな感じで実装しました
app/models/concerns/draft_module.rb
module DraftModule
extend ActiveSupport::Concern
module ClassMethods
def save_draft?(temporary_data)
return false unless temporary_data.is_a?(ActiveRecord::Base)
Draft.create(
modelname: self.table_name.classify,
properties: temporary_data.to_json
)
true
end
def load_draft
draft_data = Draft.where(["modelname = ? and publish_status = ?",self.table_name.classify , false])
return draft_data
end
end
end
app/controllers/concerns/draft_action.rb
Railsでコントローラー名からモデル名を求めたりしたい、逆もまたなどを見て、Concernモジュールを読み込んでるModel/Controller名を動的に求める方法があるのを今回初めて知りました。
module DraftAction
extend ActiveSupport::Concern
PRODUCTION_MODE = 0
def parameter_for_draft
draft_data = Draft.find(params[parameter_key][:draft_id])
draft_params = JSON.parse(draft_data.properties)
draft_params["draft_id"] = params[parameter_key][:draft_id].to_i
return draft_params
end
def save_to_draft
params_to_hash = params[parameter_key].to_hash
params_to_hash.delete("publish_status")
properties = params_to_hash.to_json
draft = Draft.new(modelname: modelname, properties: properties)
return draft.save
end
def cleanup_draft_data
draft = Draft.find(params[parameter_key]["draft_id"].to_i)
draft.publish_status = true
return draft.save
end
def load_draft
drafts = modelobject.load_draft.map do |draft|
params = JSON.parse(draft.properties)
params["draft_id"] = draft.id
draft_model = modelobject.new(params)
end
end
def is_draft?
return params[parameter_key]["draft_id"].to_i != PRODUCTION_MODE
end
def is_save_to_draft?
# POSTされた時にハッシュ値は文字列で送信されるため
# true/falseは文字列型になってるため以下の判定処理になってます
return params[parameter_key]["publish_status"] == "false"
end
private
def parameter_key
self.controller_name.singularize.to_sym
end
def modelobject
return self.controller_name.classify.constantize
end
def modelname
return self.controller_name.classify
end
end
Concernモジュールを利用するModelとControllerについて
それぞれこんな感じにします
app/models/book.rb
class Book < ActiveRecord::Base
include DraftModule
app/controllers/books_controller.rb
class BooksController < ApplicationController
include DraftAction
def index
@query = Book.search(params[:q])
books = @query.result(distinct: true)
@books = books.concat(load_draft)
end
def new
@book = Book.new
end
def create
if is_save_to_draft?
return render :new unless save_to_draft
redirect_to books_path , notice: '下書きに保存しました'
elsif is_draft?
@book = Book.new(book_params)
if @book.save
cleanup_draft_data
redirect_to books_path , notice: '下書きを公開しました'
end
else
@book = Book.new(book_params)
return render :new unless @book.save
redirect_to books_path
end
end
def draft
@book = Book.new(parameter_for_draft)
render :edit
end
# 途中省略
private
def book_params
params.require(:book).permit(
:name, :draft_id, :publish_status
)
end
end