概要
- 最近アプリばかり作っているので、冬休みに久しぶりにウェブサービスを作ることにした。
- Ruby on Railsを使って目標管理ツールを作った。(2019.11 Rails 6.0.1で動作確認)
- Herokuにデプロイした。
ちょっと遠回りで省略可能なステップもありますが、わかりやすさ重視のステップで書いていきます。
前提
- 1度くらいRuby on Railsに触れたことがある人
長くなりすぎるので、Ruby on Railsの基礎的な使い方に関しては省略。
作るもの
実際にデプロイ・公開したものはこちら http://achievements.nantekottai.com/
- 目標管理ツール
- 目標リスト(List)に、複数の目標(Achievement)を登録できる
- 目標は「達成」することができる
使うもの
- Ruby on Rails 6
- Twitter Bootstrap 3
- 間も無く4がリリースされるが、現行版は3なので3を使う。
- twitter-bootstrap-railsを使って導入
- Heroku
- Herokuのアカウントが必要
- Heroku CLIをインストールしておく
- Postgres
- HerokuではSQLiteは使えず、Postgresを使うことになる。 参考: SQLite on Heroku
エディタ
2016年現在、Railsの開発に使うエディタとしてはatomが良い感じ。
特に、この記事 AtomでRailsを爆速開発する環境を作ってみた に従ってパッケージを入れると、大分快適な開発環境が整った。browser-sync
すら不要。
2016年12月31日現在、browser-plus
はうまく動作しなかったので、代わりにplatformio-ide-terminalを使うようにした。
プロジェクトの作成
プロジェクトを作成する前に
HerokuがサポートしているRubyのバージョンを確認しておきましょう。Herokuが対応していない古いバージョンを使っている場合は、最新のRubyをインストールしてから作業を開始してください。
新しいプロジェクトの作成
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のターミナル上で、
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", "~> 2.0"
haml:erb2haml
コマンドを使うと、プロジェクト内のerbファイルをまとめてhamlファイルに変換できる。
rails haml:erb2haml
Bootstrapの導入
gem "therubyracer"
gem "twitter-bootstrap-rails"
gem "jquery-rails"
Ruby 2.4.0 + Rails 5の組み合わせでは、uglifier
で問題が発生することがあるため、修正されているtherubyracer
を使っておく。
lessを使わない場合は gem "twitter-bootstrap-rails"
だけを追加する形でOK。
bundle install
した後、generate bootstrap:install static --no-coffeescript
でプロジェクトにBootstrap関連のアセットを追加する。lessやcoffeescriptは使わないので、static
オプションと--no-coffeescript
オプションを指定した。
rails generate bootstrap:install static --no-coffeescript
また、bootstrap:layout
を実行すると、レイアウトテンプレートにbootstrapの雛形を適用できる。(現在の内容は失われるので注意)
rails g bootstrap:layout application # application.html.haml にbootstrapの雛形が適用される
ただし、生成されるレイアウトに含まれるnavbarとsidebarは今回は使わないので、消しておく。
!!! 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
Rails 5.1対策
Rails 5.1以降では、favicon_link_tag
で下記のようなエラーが発生する
The asset "apple-touch-icon-144x144-precomposed.png" is not present in the asset pipeline.
これは、faviconで指定しているアイコンがassetパイプライン上に存在しないことによるもの。本来であればアイコンを用意することが正しい解決方法になるが、アイコンを用意するのが手間な場合は一旦これらの行をコメントアウトしてしまうのが良い。
Asset application.js
was not declared to be precompiled in production. エラーが出る場合
app/assets/config/manifest.js
に//= link application.js
を追加して、サーバーを再起動する。
Herokuにデプロイする
一旦ベースの構造ができたところで、Herokuにデプロイしてみる。
gitコミットの作成
作業中のファイルを、gitレポジトリにコミットしておく。
git add .
git commit -m "lists scaffold with haml and bootstrap"
Herokuへのデプロイ
heroku create
アプリ・レポジトリができたら、pushするとデプロイが始まる。
git push heroku master
なお、Bundlerのバージョンが2.0.1
の場合にデプロイエラーが発生してしまうようだ。Could not detect rake tasks
が発生する場合は、bundle --version
でバージョンを確認し、2.0.1
だった場合は下記の手順を踏んで2.0.2
にアップデートしよう。
-
rm Gemfile.lock
で一旦古いlockファイルを削除する。 -
gem install bundler -v 2.0.2
で2.0.2
をインストールする。 -
bundle install
でlockファイルを作り直す。 -
git add
+git commit
で変更をコミットする。 -
git push heroku master
でもう一度デプロイを試す。
また、herokuでサポートされていないバージョンのrubyを使っていた場合、The Ruby version you are trying to install does not exist on this stack.
というエラーが発生する。https://devcenter.heroku.com/articles/ruby-support#supported-runtimes でサポートされているRubyのバージョンを確認し、Gemfileで指定しているrubyのバージョンを適宜変更してbundle
し直す。
初回デプロイなので、DBのマイグレーションを実行する。
heroku run rails db:migrate
マイグレーションが終わったら、ブラウザでHerokuアプリケーションのURLを開いて、Listのscaffoldが正しく動くことを確認する。
タイムゾーンの設定
デフォルトのタイムゾーン(GMT)だと日本人が使うには使い勝手が悪いので、タイムゾーンをJSTにする
heroku config:add TZ=Asia/Tokyo
機能実装
ルートパスの設定
サイトのルートにアクセスした時に、リストの一覧画面(lists#index)が表示されるようにしておく。
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
オブジェクトを保持できるようなデータ構造にする。
class List < ApplicationRecord
has_many :achievements
end
List
が複数のAchievement
を持つことをhas_many
で明示的に設定しておく。
list:references
の代わりに、list_id:integer
としても同じデータ構造になるが、references
を使うとインデックスを作ってくれるので、必要性がなければreferences
を使った方が良い。
http://localhost:3000/achievements で目標の登録ができるようになるが、目標作成時に手動でリストを入力する、というのは使い勝手的によろしくない。
URL構造を変える
目標はリスト毎に管理・表示されるものなので、まずはURLの構造から変えていきたい。http://localhost:3000/lists/#{リストのID}/achievements
というURLでリスト毎の目標一覧画面を出すように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として参照できるように改造する。
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
を編集して、ページにリストの名前が表示されるようにしてみる。
%h1=@list.name
%table
...
加えて、新しいアチーブメントを追加するページ(achievements#new)へのリンク生成箇所も、新しいnew_list_achievement_path
を使う形に書き換える。
= 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)を生成するように書き換えなければいけない。
= form_for [@list, @achievement] do |f|
第一引数に@listと@achievementの両方を渡すことによって、正しいURLを生成できるようになる。
次に、_form.html.haml
にはList
を指定するための欄があるが、List
を手動で指定させることは無いし、変な値を入力されても困るので、この項目は削除してしまおう。
.field #これいらないので消す
= f.label :list #いらない
= f.text_field :list #いらない
また、そもそも変な値を送れないようにコントローラの方でも制限しておこう。
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)
とすることで動作するようになる。
最後に、フォームの値が送信されたらインスタンスを作る部分を修正する。
def create
@achievement = @list.achievements.build(achievement_params)
新しく保存されるAchievementのインスタンスは、@listの子要素として生成されるように@list.achievements.build
で生成する。こうすることで、自動的にlist_id
に@listのIDが入る。
目標の作成に成功した後、@achievementにリダイレクトするようになっているが、
format.html { redirect_to @achievement, notice: 'Achievement was successfully created.' }
URL構造の変更によって、@achievement(achievement_path)に該当するものはなくなっている。list_achievement_path
を表示するように直してもよいが、Nameしか表示項目がないものを表示するのは微妙なので、list_achievements_path
を表示するように変更しておく。
format.html { redirect_to list_achievements_path(@list), notice: 'Achievement was successfully created.' }
ここまでの修正で、新しい目標の作成まで動作するようになっているが、このままでは一覧表示画面が正しく表示されないので、修正する。
一覧画面の修正
# GET /achievements
# GET /achievements.json
def index
@achievements = @list.achievements
end
lists/:list_id/achievements
において、全ての目標(Achievement.all)ではなく、@listにひもづく目標(@list.achievements)のみを表示させるようにする。
%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
に持ってくる。同時に、それっぽくなるように一旦ページのレイアウトを少し調整しておく。
%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"
これで不要になるachievementsのindexに加えて、Nameしか項目を持たない個別の目標(Achievement)のshowやedit、updateもあまり意味がないので、消してしまうことにした。
Rails.application.routes.draw do
root "lists#index"
resources :lists do
# index, show, edit, update の機能をルーティングから外す
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)にできるようにする。
アクションの追加
lists/:list_id/achievements/:id/achieve
で、目標を達成できるようにする。
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
の対象に含めておく。
before_action :set_achievement, only: [:destroy, :achieve]
achieve
アクションでは、@achievement
のachieved
フラグをtrue
にして、リスト画面にリダイレクトする。
def achieve
@achievement.update_attributes achieved: true
redirect_to @list, notice: "#{@achievement.name} を達成済みにしました"
end
「達成済みにする」リンクの追加
目標一覧画面で、未達成の目標には「達成済みにする」リンクを表示するようにする。
%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"
目標登録時に「達成済み」にならないようにする
最後に、新しい目標が作成されるタイミングでは「達成済み」にならないように対策をします。
フォームから項目を消す
フォームの「達成済み」チェックボックスは不要なので、消す。
.field
= f.label :achieved
= f.check_box :achieved
あわせて、コントローラ側でもachieved
パラメータを受け付けないように対策する。
def achievement_params
params.require(:achievement).permit(:name) # :achievedを消した
end
以上で、目標の達成管理まで動くようになった。
残りの機能
実際に公開しているMyAchievements 2017年の目標宣言では、レイアウトの調整に加えて、下記のような機能を実装しています。
- ListのIDをハッシュ文字列に
- パスワードによる編集制限
- 登録・達成のイベント管理
- Twitterへのシェア機能
- etc.
が、篦棒に長くなってしまうわりに内容が細かいので説明は省略します。いつか気が向いたら、完全版を書き記すかも…
では良い冬休みを。