Railsで下書き機能を独自に実装した時の過程をまとめてみた

## はじめに

Railsで業務システムっぽいものを開発しているのですが、システムの性質上、それぞれの画面での入力項目が多いこともあり、要望として下書き機能が必須という状況になってました。

当初は下書き機能を提供するgemを導入してサクッと終わらせようと思ったのですが仕様を考えるとちょっとマッチしない部分があったり、あまりメンテされてないような感じだったりと採用するのに不安を感じたので、独自に実装しました。

ある機能の実装方法や便利なgemの情報は色々見つかるのですが、

今回の下書き機能に限定しないと思うのですが、ある仕様を満たすような機能を実装する過程について情報をまとめておくことで、似たようなことに直面した方の参考になるかと思ってまとめることにしました。

過程はどうでもいいのでコードを知りたいっていう方は[最後の方にコード](http://qiita.com/h5y1m141@github/items/cccc47643910cbbb1577#下書き機能のソースコード)を貼ってありますのでそちらをご覧いただければと思います。

## 今回の仕様について

今回期待されてる下書き機能としては

- 新規作成時に下書きに保存が出来る
- 公開後は下書きに戻したりはできないで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のバージョン管理をしているか](http://qiita.com/yuku_t/items/c8eec239b7a466097663)読んでいたら、Qiitaで利用されてる実績あるので安心な反面、そもそもバージョン管理のような所が主目的で今回のような下書き→公開という一方向な状態管理でOKな場合だとちょっとオーバースペックかと思って一旦保留 |

## 結局、独自に実装することに決める

PaperTrailについて調べてる前後で以下のエントリを見つけました。

- １年前くらいに書かれた[A Lightweight Way to Handle Different Validation Situations](http://www.justinweiss.com/blog/2014/09/15/a-lightweight-way-to-handle-different-validation-situations/)
- 今年の春頃に書かれた[Blog article versioning in Ruby on Rails - a post mortem](http://leonid.shevtsov.me/en/blog-article-versioning-in-ruby-on-rails-a-post-mortem)

（だいぶ考えが飛躍しますが）これらを読んだ後にふと、Railsのポリモーフィックアソシエーションの仕組（[詳しくはこのブログ](http://o.inchiki.jp/obbr/149)が個人的にわかりやすいかと）で

```ruby
class Book < ActiveRecord::Base
  has_many :drafts, as: :resource
end

class Draft < ActiveRecord::Base
  belongs_to :resource, polymorphic: true
end
```

のような感じで実装することで仕様を満たしそうな気がしたので、独自に実装することにしました。

### 少し手を動かしてみてポリモーフィックアソシエーションは今回無理っぽいことに気づく

実装を進めて気づいたのですが今回の仕様で

> 入力項目で必須項目なものが多いが、下書き保存時には必須項目かどうかに関係なく保存することが可能

となっているので、例えばBookモデルの入力必須項目がまだ埋まってない状況で下書き保存処理に移ると、親になるBook
自体が保存できないので、それに紐づくDraftも保存できないことに気づきました :disappointed_relieved: 

あと、この段階でbook_paramsに格納されてる入力項目を手軽に、かつ、後々再利用しやすい形でどのようにDBに格納するのかも考えが及んでなかったので、ちょっとここで煮詰まってしまいました。

### Railsではないですが以前作ったスマフォアプリのキャッシュ方法を流用することで実現できそうと気づく

自分でもなぜかはわからないのですが、ふと、[以前作ったスマフォアプリのキャッシュ方法](http://h5y1m141.hatenablog.com/entry/20110418/p1)のことを思い出し、[Rails4でserializeしてデータをDBに保存させる](http://easyramble.com/rails-active-record-serialize.html)を読んでハッシュをDBにシリアライズして保存するというアプローチのことを知りました。

この辺りのアプローチをちょっと応用して、最終的に意図した実装が出来上がりました :smile: 

## おまけ

gemの調査をしている時に、それぞれのソースコードまでは読まなかったのですが、実装を追えて区切りがついたタイミングで気になってたDraftsmanのディレクトリ構造をあらためて眺めたら

```sh
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では

```ruby
class Draftsman::Draft < ActiveRecord::Base
  # Associations
  belongs_to :item, :polymorphic => true
end
```

という記述を見つけ、serializers/json.rbでは

```ruby
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経験がまだ浅いけどちょっと自信つきました :laughing: 


## 下書き機能のソースコード

### Modelについて


app/model/draft.rbをこんな感じで実装しました。本当はmodel_nameにしたかったのですが、その単語はRailsがすでに使ってる用語っぽくエラーになったのでこのようなカラム名にしてます

```ruby
# == 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

```ruby
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でコントローラー名からモデル名を求めたりしたい、逆もまた](http://o.inchiki.jp/obbr/123)などを見て、Concernモジュールを読み込んでるModel/Controller名を動的に求める方法があるのを今回初めて知りました。

```ruby
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

```ruby
class Book < ActiveRecord::Base
  include DraftModule
```

app/controllers/books_controller.rb

```ruby
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
```