1. croquette0212

    Posted

    croquette0212
Changes in title
+まだ間に合う: 冬休みなので、1日でRuby on Rails 5 + Herokuで新年の目標管理ツールを作る
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,562 @@
+# 概要
+* 最近アプリばかり作っているので、冬休みの久しぶりにウェブサービスを作ることにした。
+* Ruby on Rails 5を使って目標管理ツールを作った。
+* Herokuにデプロイした。
+
+ちょっと遠回りで省略可能なステップもありますが、わかりやすさ重視のステップで書いていきます。
+
+## 前提
+* SQLiteやPostgresに触れたことがある
+* Ruby on Railsに触れたことがある
+
+長くなりすぎるので、Ruby on Railsの基礎的な使い方に関しては省略。
+
+# 作るもの
+実際にリリースしたものはこちら http://achievements.nantekottai.com/
+
+![スクリーンショット 2017-01-02 15.18.23.png](https://qiita-image-store.s3.amazonaws.com/0/14097/37afa13a-a8c6-2f63-fc9a-a97e4f913472.png)
+
+* 目標管理ツール
+* 目標リスト(List)に、複数の目標(Achievement)を登録できる
+* 目標は「達成」することができる
+
+# 使うもの
+* Ruby on Rails 5
+ * WebSocketを扱う`Action Cable`やAPIモードが追加された。今回はそこらへんは使わないが、一応最新版を使っておく。
+ * 5の新機能については下記参照
+ * http://railsguides.jp/5_0_release_notes.html
+ * http://qiita.com/ryohashimoto/items/09724ad4dddcda01bce3
+ * http://qiita.com/jnchito/items/6a93320334c48b967dfb
+* Twitter Bootstrap 3
+ * 間も無く4がリリースされるが、現行版は3なので3を使う。
+ * [twitter-bootstrap-rails](https://github.com/seyhunak/twitter-bootstrap-rails)を使って導入
+* Heroku
+ * Herokuのアカウントが必要
+ * [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)をインストールしておく
+* Postgres
+ * HerokuではSQLiteは使えず、Postgresを使うことになる。 参考: [SQLite on Heroku](https://devcenter.heroku.com/articles/sqlite3)
+
+# エディタ
+2016年現在、Railsの開発に使うエディタとしてはatomが良い感じ。
+
+![atom-rails.png](https://qiita-image-store.s3.amazonaws.com/0/14097/abe69f9a-501c-2dcb-639d-e508a8b356ed.png)
+
+特に、この記事 [AtomでRailsを爆速開発する環境を作ってみた](http://qiita.com/tacumai/items/e84e586b5bde2979a066) に従ってパッケージを入れると、大分快適な開発環境が整った。`browser-sync`すら不要。
+
+2016年12月31日現在、`browser-plus`はうまく動作しなかったので、代わりに[platformio-ide-terminal](https://atom.io/packages/platformio-ide-terminal)を使うようにした。
+
+# プロジェクトの作成
+
+```
+rails new my-achievements -d postgresql
+```
+
+Heroku上で運用することを考えると、最初からPostgresを使う設定でプロジェクトを作っておいた方が良い。参考: https://devcenter.heroku.com/articles/sqlite3
+
+プロジェクトを生成したら、atomを起動する。
+
+```
+cd my-achievements
+atom .
+```
+
+## DBの作成
+初回なので、`development`と`test`用のPostgresのDBを作成する。
+
+atom上で、`Ctrl`+`Shift`+`@`でターミナルを起動したら、`db:create`を実行する。(Rails 5から、`rake`コマンドではなく`rails`コマンドを使う形が推奨されるようになった)
+
+```
+rails db:create
+```
+
+ここでエラーが起きる場合は、Postgresのインストールに失敗している、サービスの起動の設定がされていない可能性があるので、`brew info postgres`などで確認する。
+
+## 動作確認
+まだ何もない状態だが、DBの作成までできたところで一旦起動確認しておく。
+
+![atom-browser.png](https://qiita-image-store.s3.amazonaws.com/0/14097/c66feeb6-5273-87c4-6fc5-cf03830ef77a.png)
+
+* atomのターミナル上で、`rails s`コマンドでサーバーを起動する。
+* `Ctrl`+`Opt`+`O`で、ブラウザータブを開き、`http://localhost:3000/`を開く。
+* 右上の雷マークを押しておくと、ライブリロードが有効になる。(ファイルを更新すると自動的にページが再読み込みされる)
+
+## gitレポジトリの作成
+Herokuにデプロイするタイミングでも必要になるので、プロジェクト用のgitレポジトリを作っておく。
+
+```
+git init
+git add .
+git commit -m "First commit"
+```
+
+# Scaffoldで土台を作る
+
+一旦`Ctrl`+`C`でrailsサーバーを終了してから、`rails`コマンドで`List`のscaffoldを作り、マイグレーションを走らせる。その後、`rails s`でサーバーを再起動する。
+
+```
+rails g scaffold List name:string
+rails db:migrate
+rails s
+```
+
+ブラウザタブで`http://localhost:3000/lists`を開き、`List`のscaffoldが正しく動作することを確認する。
+
+## 自動リロードの確認
+`http://localhost:3000/lists`を開いた状態で、`index.html.erb`の中身を編集・保存すると、ブラウザの自動リロードが発生して画面に変更が反映されることを確認する。
+
+# HamlとBootstrapの導入
+## Hamlの導入
+
+Gemfileに`haml-rails`を追加して、`bundle install`する。
+
+```
+gem "haml-rails", "~> 0.9"
+```
+
+`haml:erb2haml`コマンドを使うと、プロジェクト内のerbファイルをまとめてhamlファイルに変換できる。
+
+```
+rails haml:erb2haml
+```
+
+## Bootstrapの導入
+
+```
+gem 'therubyracer', platforms: :ruby, github: 'cowboyd/therubyracer'
+gem "less-rails" #Sprockets (what Rails 3.1 uses for its asset pipeline) supports LESS
+gem "twitter-bootstrap-rails"
+```
+
+Ruby 2.4.0 + Rails 5の組み合わせでは、`uglifier`で問題が発生することがあるため、修正されている`therubyracer`を使っておく。
+
+`bundle install`した後、`generate bootstrap:install less`でプロジェクトにBootstrap関連のアセットを追加する。
+
+```
+rails generate bootstrap:install less
+```
+
+また、`bootstrap:layout`を実行すると、レイアウトテンプレートにbootstrapの雛形を適用できる。(現在の内容は失われるので注意)
+
+```
+rails g bootstrap:layout application # application.html.haml にbootstrapの雛形が適用される
+```
+
+ただし、生成されるレイアウトに含まれるnavbarとsidebarは今回は使わないので、消しておく。
+
+```haml:application.html.haml
+!!! 5
+%html(lang="en")
+ %head
+ %meta(charset="utf-8")
+ %meta(http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1")
+ %meta(name="viewport" content="width=device-width, initial-scale=1.0")
+ %title= content_for?(:title) ? yield(:title) : "MyAchievements"
+ = csrf_meta_tags
+ = stylesheet_link_tag "application", :media => "all"
+ = favicon_link_tag 'apple-touch-icon-144x144-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '144x144'
+ = favicon_link_tag 'apple-touch-icon-114x114-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '114x114'
+ = favicon_link_tag 'apple-touch-icon-72x72-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png', :sizes => '72x72'
+ = favicon_link_tag 'apple-touch-icon-precomposed.png', :rel => 'apple-touch-icon-precomposed', :type => 'image/png'
+ = favicon_link_tag 'favicon.ico', :rel => 'shortcut icon'
+ = javascript_include_tag "application"
+ / Le HTML5 shim, for IE6-8 support of HTML elements
+ /[if lt IE 9]
+ = javascript_include_tag "//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.2/html5shiv.min.js"
+
+
+ %body
+ = bootstrap_flash
+ = yield
+ %footer
+ %p © Company 2017
+
+```
+
+# Herokuにデプロイする
+一旦ベースの構造ができたところで、Herokuにデプロイしてみる。
+
+## gitコミットの作成
+作業中のファイルを、gitレポジトリにコミットしておく。
+
+```
+git add .
+git commit -m "lists scaffold with haml and bootstrap"
+```
+
+## Herokuへのデプロイ
+```
+heroku create
+```
+
+アプリ・レポジトリができたら、pushするとデプロイが始まる。
+
+```
+git push heroku master
+```
+
+初回デプロイなので、DBのマイグレーションを実行する。
+
+```
+heroku run rails db:migrate
+```
+
+マイグレーションが終わったら、ブラウザでHerokuアプリケーションのURLを開いて、Listのscaffoldが正しく動くことを確認する。
+
+## タイムゾーンの設定
+TBD
+
+# 機能実装
+## ルートパスの設定
+サイトのルートにアクセスした時に、リストの一覧画面(lists#index)が表示されるようにしておく。
+
+```ruby:config/routes.rb
+Rails.application.routes.draw do
+ root "lists#index"
+ resources :lists
+end
+```
+
+## リストの下に目標を追加できるようにする
+結論から言うと、ここで作るscaffoldの内容は、最終的にはほとんど不要になって削除してしまうことになる。なので、完成形が見えている場合はscaffoldを使わなくても良いのだが、データ構造が明らかになってそれが正しく動く様子を確認してからUIに合わせて内容を書き換えていく方が分かりやすいと思うので、少し遠回りだがscaffoldを使って作っていく。
+
+### 目標(Achievement)のscaffold
+```
+rails g scaffold Achievement name:string list:references achieved:boolean
+rails db:migrate
+```
+
+`List`が複数の`Achievement`オブジェクトを保持できるようなデータ構造にする。
+
+```models/list.rb
+class List < ApplicationRecord
+ has_many :achievements
+end
+```
+
+`List`が複数の`Achievement`を持つことを`has_many`で明示的に設定しておく。
+
+`list:references`の代わりに、`list_id:integer`としても同じデータ構造になるが、`references`を使うとインデックスを作ってくれるので、必要性がなければ`references`を使った方が良い。
+
+http://edgeguides.rubyonrails.org/active_record_migrations.html#special-helpers
+
+![スクリーンショット 2017-01-02 18.59.04.png](https://qiita-image-store.s3.amazonaws.com/0/14097/4caa9ac7-d753-9638-c0ca-f27f2337bed9.png)
+
+http://localhost:3000/achievements で目標の登録ができるようになるが、目標作成時に手動でリストを入力する、というのは使い勝手的によろしくない。
+
+### URL構造を変える
+目標はリスト毎に管理・表示されるものなので、まずはURLの構造から変えていきたい。`http://localhost:3000/lists/#{リストのID}/achievements`というURLでリスト毎の目標一覧画面を出すように`routes.rb`を書き換える。
+
+```ruby:config/routes.rb
+Rails.application.routes.draw do
+ root "lists#index"
+ resources :lists do
+ resources :achievements
+ end
+end
+```
+
+これで、`lists/#{リストID}/achievements`というURLでアチーブメント一覧画面にアクセスできるようになるが、実際に`http://localhost:3000/lists/#{リストのID}/achievements`を開くと`new_achievement_path`の箇所でエラーになる。ルーティングの設定が変わったため、`new_achievement_path`というパスが存在しなくなったためだ。
+
+ルーティング関連の問題が起きた時は`rails routes`で有効なパスの一覧を確認する。
+
+```
+ Prefix Verb URI Pattern Controller#Action
+ root GET / lists#index
+ list_achievements GET /lists/:list_id/achievements(.:format) achievements#index
+ POST /lists/:list_id/achievements(.:format) achievements#create
+ new_list_achievement GET /lists/:list_id/achievements/new(.:format) achievements#new
+edit_list_achievement GET /lists/:list_id/achievements/:id/edit(.:format) achievements#edit
+ list_achievement GET /lists/:list_id/achievements/:id(.:format) achievements#show
+ PATCH /lists/:list_id/achievements/:id(.:format) achievements#update
+ PUT /lists/:list_id/achievements/:id(.:format) achievements#update
+ DELETE /lists/:list_id/achievements/:id(.:format) achievements#destroy
+ lists GET /lists(.:format) lists#index
+ POST /lists(.:format) lists#create
+ new_list GET /lists/new(.:format) lists#new
+ edit_list GET /lists/:id/edit(.:format) lists#edit
+ list GET /lists/:id(.:format) lists#show
+ PATCH /lists/:id(.:format) lists#update
+ PUT /lists/:id(.:format) lists#update
+ DELETE /lists/:id(.:format) lists#destroy
+```
+
+`new_achievement`の代わりに`new_list_achievement`が登場しているため、これに置き換える。
+
+### 親となるListの取得
+ただし、この修正をするにあたっては、少し下準備をする必要がある。
+まず、`achievements_controller`は`lists`の下にネストされた状態になった。今後、`achievements_controller`は`:list_id`で指定されたListに紐づいて動くようにする必要がある。
+
+そこで、`achievements_controller`の`before_action`で各アクションの実行前にIDが`:list_id`となるListを@listとして参照できるように改造する。
+
+```ruby:achievements_controller.rb
+class AchievementsController < ApplicationController
+ before_action :set_list # ここ追加
+ before_action :set_achievement, only: [:show, :edit, :update, :destroy]
+ ...
+ private
+ def set_list # private以下にこのメソッド追加
+ @list = List.find(params[:list_id])
+ end
+
+ ...
+ def set_achievement
+ ...
+end
+```
+
+ここで、`achievements/index.html.haml`を編集して、ページにリストの名前が表示されるようにしてみる。
+
+```achievements/index.html.haml
+%h1=@list.name
+
+%table
+...
+```
+
+加えて、新しいアチーブメントを追加するページ(achievements#new)へのリンク生成箇所も、新しい`new_list_achievement_path`を使う形に書き換える。
+
+```achievements/index.html.haml
+= link_to 'New Achievement', new_list_achievement_path(@list)
+```
+
+これで、目標一覧ページ(lists/#{リストのID}/achievements)を表示できるようになった。まだ目標は一つも登録されていないので何も表示されないし、登録したとしてもリスト関係なく全ての目標が表示されてしまうが、それは後で直していく。
+
+### 新しい目標の作成ページの実装
+次に、新しい目標の作成ページを修正する。
+
+新規作成画面(new.html.haml)は、内部的に`_form.html.haml`を参照している。`_form.html.haml`では、目標の設定のためのフォームが定義されている。ここで使用している`form_for`タグは、元々`@achievement`の登録(・更新)のためのURL(achievements/:id)を生成するようになっていたが、URL構造の変更により(lists/:list_id/achievements/:id)を生成するように書き換えなければいけない。
+
+```achievements/_form.html.haml
+= form_for [@list, @achievement] do |f|
+```
+
+第一引数に@listと@achievementの両方を渡すことによって、正しいURLを生成できるようになる。
+
+次に、`_form.html.haml`には`List`を指定するための欄があるが、`List`を手動で指定させることは無いし、変な値を入力されても困るので、この項目は削除してしまおう。
+
+```haml:achievements/_form.html.haml
+ .field #これいらないので消す
+ = f.label :list #いらない
+ = f.text_field :list #いらない
+```
+
+また、そもそも変な値を送れないようにコントローラの方でも制限しておこう。
+
+```achievements_controller.rb
+ def achievement_params
+ params.require(:achievement).permit(:name, :achieved) #:list_idを消しておく
+ end
+```
+
+もう一箇所、`new.html.haml`では、ページ下部に「Back」リンクがあり、そこで存在しないパス(`achievements_path`)が指定されているためエラーが発生している。
+
+```
+= link_to 'Back', achievements_path
+```
+
+これは、`list_achievements_path(@list)`とすることで動作するようになる。
+
+最後に、フォームの値が送信されたらインスタンスを作る部分を修正する。
+
+```ruby:achievements_controller.rb
+ def create
+ @achievement = @list.achievements.build(achievement_params)
+```
+
+新しく保存されるAchievementのインスタンスは、@listの子要素として生成されるように`@list.achievements.build`で生成する。こうすることで、自動的に`list_id`に@listのIDが入る。
+
+目標の作成に成功した後、@achievementにリダイレクトするようになっているが、
+
+```ruby:achievements_controller.rb
+format.html { redirect_to @achievement, notice: 'Achievement was successfully created.' }
+```
+
+URL構造の変更によって、@achievement(achievement_path)に該当するものはなくなっている。`list_achievement_path`を表示するように直してもよいが、Nameしか表示項目がないものを表示するのは微妙なので、`list_achievements_path`を表示するように変更しておく。
+
+```ruby:achievements_controller.rb
+format.html { redirect_to list_achievements_path(@list), notice: 'Achievement was successfully created.' }
+```
+
+ここまでの修正で、新しい目標の作成まで動作するようになっているが、このままでは一覧表示画面が正しく表示されないので、修正する。
+
+### 一覧画面の修正
+
+```ruby:achievements_controller.rb
+ # GET /achievements
+ # GET /achievements.json
+ def index
+ @achievements = @list.achievements
+ end
+```
+
+`lists/:list_id/achievements`において、全ての目標(Achievement.all)ではなく、@listにひもづく目標(@list.achievements)のみを表示させるようにする。
+
+```achievements/index.html.haml
+%td= link_to 'Show', list_achievement_path(@list, achievement)
+%td= link_to 'Edit', edit_list_achievement_path(@list, achievement)
+%td= link_to 'Destroy', list_achievement_path(@list, achievement), :method => :delete, :data => { :confirm => 'Are you sure?' }
+```
+
+また、今までと同じ要領でパスの修正を行う。
+
+### その他の画面のパスの修正
+同じ要領で、Show / Edit / Destroyアクションに関連する各パス生成箇所も修正する。
+
+## UIの改良
+### lists#showとachievements#indexを統合する
+`lists#show`も`achievements#index`も、リストに含まれる目標の一覧を表示する、という意味合いでは同じ目的のページとなるので、どちらか一つに統合してしまおう。どちらを残す形でも良いが、URLが短くなる方が嬉しいので、`lists#show`を残して`achievements#index`を削除してしまおう。
+
+#### lists#showで目標の一覧を表示する
+`achievements#index`で表示している目標一覧の実装を`lists#show`に持ってくる。同時に、それっぽくなるように一旦ページのレイアウトを少し調整しておく。
+
+```lists/show.html.haml
+%h1=@list.name
+= link_to '編集', edit_list_path(@list)
+
+%h2 目標一覧
+%table.table
+ %thead
+ %tr
+ %th Name
+ %th
+
+ %tbody
+ - @list.achievements.each do |achievement|
+ %tr
+ %td
+ = achievement.name
+ - if achievement.achieved
+ (達成済み)
+ %td= link_to '削除する', list_achievement_path(@list, achievement), :method => :delete, :data => { :confirm => 'Are you sure?' }, :class => "btn btn-danger"
+
+%br
+
+= link_to '新しい目標', new_list_achievement_path(@list), class: "btn btn-primary"
+= link_to '戻る', lists_path, class: "btn btn-default"
+```
+
+![スクリーンショット 2017-01-02 18.36.59.png](https://qiita-image-store.s3.amazonaws.com/0/14097/33648a95-3542-5884-c851-1f73ce31719d.png)
+
+Nameしか項目を持たない個別の目標(Achievement)のshowやedit、updateはあまり意味がないので、消してしまうことにした。
+
+```config/routes.rb
+Rails.application.routes.draw do
+ root "lists#index"
+ resources :lists do
+ resources :achievements, except: [:index, :show, :edit, :update]
+ end
+end
+```
+
+show, edit, updateの各アクションに関する`achievements_controller`内の実装も消してしまおう。`before_action :set_achievement, only: [:destroy]`の`only:`対象から削除するのも忘れずに。
+
+下記のビューファイルも不要なので消しちゃう。(`new`と`_form`だけが残る)
+
+* views/achievements/show.html.haml
+* views/achievements/show.json.jbuilder
+* views/achievements/edit.html.haml
+* views/achievements/index.html.haml
+* views/achievements/index.json.jbuilder
+* views/achievements/_achievements.json.jbuilder
+
+そして、achievements_controller内、目標の作成後など`list_achievements_path`にリダイレクトしていた箇所は、全て`@list`にリダイレクトするように書き換える。
+
+```
+format.html { redirect_to @list, notice: 'Achievement was successfully created.' }
+```
+
+ここまで作って、ようやくリストの作成〜目標の作成までが画面として繋がった。
+
+## 達成機能
+目標を達成したら達成済み(achieved=true)にできるようにする。
+
+![スクリーンショット 2017-01-02 19.59.00.png](https://qiita-image-store.s3.amazonaws.com/0/14097/5e491f3b-7aab-74c5-7e7d-ad8936a77d84.png)
+
+### アクションの追加
+`lists/:list_id/achievements/:id/achieve`で、目標を達成できるようにする。
+
+```routes.rb
+Rails.application.routes.draw do
+ root "lists#index"
+ resources :lists do
+ resources :achievements, except: [:index, :show, :edit, :update] do
+ member do
+ post "achieve"
+ end
+ end
+ end
+end
+```
+
+`achievements_controller`に、`achieve`アクションを定義していく。アクションを定義する前に、新しく追加する`achieve`アクション内でも@achievementを参照できるように、`set_achievement`の対象に含めておく。
+
+```achievements_controller.rb
+before_action :set_achievement, only: [:destroy, :achieve]
+```
+
+`achieve`アクションでは、`@achievement`の`achieved`フラグを`true`にして、リスト画面にリダイレクトする。
+
+```achievements_controller.rb
+ def achieve
+ @achievement.update_attributes achieved: true
+ redirect_to @list, notice: "#{@achievement.name} を達成済みにしました"
+ end
+```
+
+### 「達成済みにする」リンクの追加
+
+目標一覧画面で、未達成の目標には「達成済みにする」リンクを表示するようにする。
+
+```lists/show.html.haml
+ %tbody
+ - @list.achievements.each do |achievement|
+ %tr
+ %td
+ = achievement.name
+ - if achievement.achieved
+ (達成済み)
+ - else
+ = link_to '達成済みにする', achieve_list_achievement_path(@list, achievement), method: 'POST', data: { confirm: '達成済みにしますか?一度達成済みにすると、元に戻せません。' }, class: "btn btn-xs btn-default"
+ %td= link_to '削除する', list_achievement_path(@list, achievement), :method => :delete, :data => { :confirm => 'Are you sure?' }, :class => "btn btn-danger"
+```
+
+### 目標登録時に「達成済み」にならないようにする
+最後に、新しい目標が作成されるタイミングでは「達成済み」にならないように対策をします。
+
+#### フォームから項目を消す
+フォームの「達成済み」チェックボックスは不要なので、消す。
+
+```achievements/_form.html.haml
+ .field
+ = f.label :achieved
+ = f.check_box :achieved
+```
+
+あわせて、コントローラ側でも`achieved`パラメータを受け付けないように対策する。
+
+```achievements_controller.rb
+def achievement_params
+ params.require(:achievement).permit(:name) # :achievedを消した
+end
+```
+
+以上で、目標の達成管理まで動くようになった。
+
+## 残りの機能
+
+実際に公開している[MyAchievements 2017年の目標宣言](http://achievements.nantekottai.com)では、レイアウトの調整に加えて、下記のような機能を実装しています。
+
+* ListのIDをハッシュ文字列に
+* パスワードによる編集制限
+* 登録・達成のイベント管理
+* Twitterへのシェア機能
+* etc.
+
+が、篦棒に長くなってしまうわりに内容が細かいので説明は省略します。いつか気が向いたら、完全版を書き記すかも…
+
+では良い冬休みを。
+
+# 参考
+* [RailsのScaffoldでネストしたResourceを作る](http://sil.hatenablog.com/entry/rails-nested-resource-by-scaffold)
+* [
+ruby 2.4.0 on rails 5.0.1にした際につまづいた点](http://www.lanches.co.jp/blog/6330)