Alex Liang

Uploading image to AWS S3 with Paperclip

這二天將專案deploy到Heroku,卻因為paperclip和aws-sdk版本問題卡了好久
把解決過程記錄下來,以免未來再踩雷

一開始,照著Heroku 官方教學實作。
沒注意到文章表明此範例不支援aws-sdk 2之後的版本,以及沒指定paperclip的版本,導致圖片上傳失敗

事前準備

1. 必須要有AWS S3的帳號,設定方法可參考[這篇](http://alexliang-blog.logdown.com/posts/385515-aws-uses-s3-for-uploading-images)
2. 己安裝ImageMagick
3. 己安裝Heroku Toolbelt

設定

Gemfile
1
2
gem "paperclip", '~> 4.3.6'
gem 'aws-sdk', '< 2.0'

因為paperclip對新版的aws-sdk尚未支援,aws-sdk採用2.0以前版本;paperclip用穩定版本

config/environments/production.rb
1
2
3
4
5
6
7
8
config.paperclip_defaults = {
:storage => :s3,
:s3_credentials => {
:bucket => ENV['S3_BUCKET_NAME'],
:access_key_id => ENV['AWS_ACCESS_KEY_ID'],
:secret_access_key => ENV['AWS_SECRET_ACCESS_KEY']
}
}

這裡使用ENV表示機密資訊,你可以在application.yml設定其值(注意此yml檔要加入.gitignore)
但heroku不知道這些值,所以要另外設定

1
2
3
$ heroku config:set S3_BUCKET_NAME=your_bucket_name
$ heroku config:set AWS_ACCESS_KEY_ID=your_access_key_id
$ heroku config:set AWS_SECRET_ACCESS_KEY=your_secret_access_key

新增paperclip.rb檔在config/initializers下

config/initializers/paperclip.rb
1
2
3
4
5
if Rails.env.production?
Paperclip::Attachment.default_options[:url] = your_bucket_url
Paperclip::Attachment.default_options[:path] = '/:class/:attachment/:id_partition/:style/:filename'
Paperclip::Attachment.default_options[:s3_host_name] = 's3-us-west-1.amazonaws.com'
end

要注意:url的值為’bucket_name’+s3.amazonaws.com,例如bucket取名paperclip-test,則url為paperclip-test.s3.amazonaws.com
而:s3_host_name則需參考AWS文件做相對應的修改。

參考資料:
Stack Overflow討論文章
Paperclip github

Check list of Heroku deployment

在deploy到Heroku前,應該檢查的事:

開Branch

因為你永遠不知道deploy後有什麼狀況,修改branch也比較安全
事實上,在修改bug、開發沒做過的功能前,你都應該新增branch

Devise

如果有裝Devise,記得加上這段

config/initializers/devise.rb
1
config.secret_key = ENV["DEVISE_SECRET"]

1
$ heroku config:set DEVISE_SECRET=XXX

Database

因為Heroku不支援sqlite3,production改用postgresql

Gemfile
1
2
3
4
5
6
7
group :development, :test do
gem 'sqlite3'
end

group :production do
gem 'pg'
en

記得bundle install

參考資料:
Stack Overflow文章
設定production database

[Rails] 使用friendly_id增加URL可讀性

在預設的routing和URL中,每筆資料皆以id做查詢及顯示。
例如網站有個post model及controller,則第一則post id為1,其URL為/posts/1。
這對網站SEO及可讀性來說是很糟糕的做法。

如果要使用有意義的欄位(例如post title)作為URL的一部分,我們可以覆寫to_param並使用parameterize將id加上post title

app/models/post.rb
1
2
3
4
5
class Post < ActiveRecord::Base
def to_param
"#{id}-#{title}".parameterize
end
end

透過此方法,假如第一則post title為rails routing,則本來的//post/1會變成//post/1-rails-routing。
但這樣還是有id number在URL。想更進一步去除id,需要在post model加入名為slug的欄位

1
2
>> rails g migration AddSlugToPosts slug
>> rake db:migrate

修改post model

app/models/post.rb
1
2
3
4
5
6
7
8
9
10
11
class Post < ActiveRecord::Base
before_save :update_slug

def update_slug
self.slug = title.parameterize # 在儲存post前,先將title參數化存到slug
end

def to_param
slug
end
end

app/controllers/posts_controller.rb
1
2
3
4
5
6
7
class PostsController < ActionController	
... 略

def find_params
@post = Post.find_by_slug(params[:id]) # 由於id在to_param時己改為slug,找資料時得用slug
end
end

以上介紹的方法最大的問題是當title被更改後(例如rails routing 2),則URL會成為//posts/rails-routing-2
舊的網址將會失效,這對使用者來說會是非常不便的事。

因此介紹friendly_id這套gem,它能幫我們把URL變美觀,且省下很多力氣

Install

Gemfile
1
gem 'friendly_id', '~> 5.1.0' # Note: You MUST use 5.0.0 or greater for Rails 4.0+

接著bundle install以完成安裝

1
2
>> rails generate friendly_id
>> rake db:migrate

Model and Controller

app/models/post.rb
1
2
3
4
5
class Post < ActiveRecord::Base
extend FriendlyId
friendly_id :title, use: :slugged
... 略
end
app/controllers/posts_controller.rb
1
2
3
4
5
6
7
class PostsController < ActionController	
... 略

def find_params
@post = Post.friendly.find(params[:id])
end
end

將本來的find前面加上friendly這個method,才能找到資料。

參考來源:
Friendly ID介紹影片
friendly_id github

[Rails] 使用gmaps4rails增加Google Map

新專案需要將地址放在google map顯示,於是使用gmaps4rails實作
經過一陣鬼打牆的debug後才搞定這個功能,這裡記錄下來,也提醒自己別再犯錯。

Install gmaps4rails

Gemfile
1
2
gem 'gmaps4rails'
gem 'geocoder' # 協助轉助座標

然後bundle 安裝gem

Javascript Dependencies

這段code需要放在view裡面

1
2
3
<script src="//maps.google.com/maps/api/js?v=3.18&sensor=false&client=&key=&libraries=geometry&language=&hl=&region="></script> 
<script src="//google-maps-utility-library-v3.googlecode.com/svn/tags/markerclustererplus/2.0.14/src/markerclusterer_packed.js"></script>
<script src='//google-maps-utility-library-v3.googlecode.com/svn/tags/infobox/1.1.9/src/infobox_packed.js' type='text/javascript'></script> <!-- only if you need custom infoboxes -->

接著在vendor/assets/javascripts底下新增underscore.js
並且將這段程式碼放入

Javascript Source Code

app/assets/javascripts/application.js
1
2
//= require underscore
//= require gmaps/google

Views

app/views/posts/show.html.erb
1
2
3
<div style='width: 800px;'>
<div id="map" style='width: 800px; height: 400px;'></div>
</div>

Javascript Code

在view底下加入javascript code,這段會顯示一個標記在經緯度原點的地圖

app/views/posts/show.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script type="text/javascript">
handler = Gmaps.build('Google');
handler.buildMap({ provider: {}, internal: {id: 'map'}}, function(){
markers = handler.addMarkers([
{
"lat": 0,
"lng": 0,
"picture": {
"url": "http://people.mozilla.com/~faaborg/files/shiretoko/firefoxIcon/firefox-32.png",
"width": 32,
"height": 32
},
"infowindow": "hello!"
}
]);
handler.bounds.extendWith(markers);
handler.fitMapToBounds();
});
</script>

Controller

假設我們有一個post controller和model,其中有個欄位是address
此欄位需要轉換為經緯度,並且在controller建立一個hash table做為google map標記的資料

app/controllers/posts_controller.rb
1
2
3
4
5
6
7
8
9
10
11
class PostsController < ApplicationController
...略
def show
@post = Post.find(params[:id])
@hash = Gmaps4rails.build_markers(@post) do |post, marker|
marker.lat post.latitude
marker.lng post.longitude
end
end

end

這裡要注意的是建立hash這段code要放在相對應的action,本來照著官網的示範做(它是放在index),一直都失敗
才發現是hash沒建出來

Model

app/models/post.rb
1
2
3
4
5
class Post < ActiveRecord::Base
validates :title, :address, presence: true
geocoded_by :address
after_validation :geocode
end

需要使用geocoded_by才能轉換至經緯度座楆

以上都設定好後,可以回頭修改script

Javascript Code

在view底下加入javascript code,這段會顯示一個標記在經緯度原點的地圖

app/views/posts/show.html.erb
1
2
3
4
5
6
7
8
9
10
<script type="text/javascript">
handler = Gmaps.build('Google');
handler.buildMap({ provider: {}, internal: {id: 'map'}}, function(){
markers = handler.addMarkers(<%=raw @hash.to_json %>); # 將hash table轉成json格式當標記
handler.bounds.extendWith(markers);
handler.fitMapToBounds();
handler.getMap().setZoom(15); # 預設zoom為15
}
);
</script>

到這裡就完成google map的顯示了,如果還需要其它功能(畫路徑、客製化圖層)請參考官方說明

參考來源:
Youtube教學影片
gmaps4rails github
geocoder github
“Change default zoom” from Stack Overflow

[Rails] KH Badminton Map計算聚會時間及使用helper整理views

上一篇搞定球聚時間的選單後,我們需要計算下次球聚的日期 (只顯示星期幾而沒有日期,不方便記日子)
藉由Ruby的Time class和rails的helper,可以將計算的過程藏在後面,而不弄亂前端的view

首先,因為球聚的時間是記錄星期幾,在model裡僅是個數字 (0~6表示週日到週六)
下次球聚的日期會根據使用者看到文章的當天計算
例如,預計週三聚會,使用者週四才看到文章。此時需顯示下次聚會日期 (也就是下週三)

Time Class

Ruby的Time class提供wday,可得知日期對應到星期幾,例如

1
2
3
4
2.2.0 :009 > t = Time.now
=> 2016-04-22 15:20:42 +0800
2.2.0 :010 > t.wday
=> 5

View

app/views/posts/show.html.erb
1
2
3
4
...略
<p>
時間:<%= next_meetup_date(@post.day.to_i) %>
</p>

Helper

day_offset是預計週幾聚會,利用Time.now.wday和它之間的關係算出下次聚會日期
next_meetup_date除了顯示日期,還加上星期幾

app/helper/posts_helper.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module PostsHelper
def calculate_date(day_offset)
meetup_day = day_offset
current_day = Time.now.wday
next_date = DateTime.now + (meetup_day - current_day).days

if meetup_day < current_day
next_date += 7.days # Next week
end
next_date.strftime('%Y/%m/%d')
end

def next_meetup_date(day_offset)
calculate_date(day_offset) + ' (' + I18n.t(:"date.day_names")[day_offset] + ')'
end
end

使用helper的好處是將來如果要改變顯示格式,只要改一個地方就好,其它的view也能使用。

參考來源:
Ruby Time文件
strftime 格式化時間的說明

[Rails] 使用i18n轉換中文詞彙

網站的頁面需要提供下拉式選單供使用者選擇星期幾
一開始使用Date::DAYNAMES取得一週的詞彙:

app/views/posts/_form.html.erb
1
2
3
4
5
6
<%= simple_form_for @post do |f| %>
...略
<p>
球聚時間:每週 <%= f.select :day, Date::DAYNAMES.zip((0..6).to_a) %>
</p>
<% end %>

然而預設為英文單字

1
=> [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]]

我們想改成中文顯示,於是使用i18n轉換字詞

首先,在config/locales下新增一個zh-TW.yml檔,裡面建立中文的對應單字

config/locales/zh-TW.yml
1
2
3
4
5
6
7
8
9
10
"zh-TW":
date:
day_names:
- 星期日
- 星期一
- 星期二
- 星期三
- 星期四
- 星期五
- 星期六

要特別注意的是yml檔裡不能用tab來縮排,必需使用2個空白

接著設定config

config/application.rb
1
config.i18n.default_locale = 'zh-TW'

修改原來的view,即可轉換選單的英文字詞

app/views/posts/_form.html.erb
1
2
3
4
5
6
<%= simple_form_for @post do |f| %>
...略
<p>
球聚時間:每週 <%= f.select :day, t(:"date.day_names").zip((0..6).to_a) %>
</p>
<% end %>

參考來源:
“How to store and display a day of the week” from Stack Overflow
ihower介紹i118n
設定yml檔 from Stack Overflow

[Ruby] 1-D Array to Hash

在實作寄信功能時,需要把一維陣列轉成hash,於是思維還沒轉成ruby的我就寫下這段很初學者的code

原來的code,為了建立mail_list這個hash,花了6行程式碼

app/controllers/goals_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GoalsController < ApplicationController
...略
def generate_json_params
mail_list = {}
count = 0
for email in @goal.shared_mails
mail_list[count.to_s] = email.mail_addr
count+=1
end

h = JSON.generate({ 'owner' => @goal.owner.name,
'goal' => @goal.title,
'complete_date' => @goal.complete_date,
'email' => mail_list })
end
end

使用Ruby內建的Hash[]可將陣列轉成Hash,再搭配with_index以index當key,一行搞定!

app/controllers/goals_controller.rb
1
2
3
4
5
6
7
8
9
10
class GoalsController < ApplicationController	
...略
def generate_json_params
mail_list = Hash[ @goal.shared_mails.map.with_index {|x, i| [i, x.mail_addr]}]
h = JSON.generate({ 'owner' => @goal.owner.name,
'goal' => @goal.title,
'complete_date' => @goal.complete_date,
'email' => mail_list })
end
end

(沒想到重構一個function居然可以寫3篇文章 XD)

參考來源:
ihower blog文章

[Rails] 重構controllers

上一篇我們介紹使用sidekiq和ActionMailer處理寄信功能。
原本要寄信時還得手動按下按鈕且controller寫得不夠乾淨,這次選擇重構controller

原來的controller

app/controllers/goals_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class GoalsController < ApplicationController
before_action :find_params, only: [:edit, :update, :destroy, :complete, :notify_friend]
...略

def notify_friend
mail_list = {}
count = 0
for email in @goal.shared_mails
mail_list[count.to_s] = email.mail_addr
count+=1
end

h = JSON.generate({ 'owner' => @goal.owner.name,
'goal' => @goal.title,
'complete_date' => @goal.complete_date,
'email' => mail_list })
SendEmailJob.perform_now(h)
redirect_to goals_path
end

private

def find_params
@goal = current_user.goals.find(params[:id])
end
end

使用after_action,在更新目標的分享對象後發信。並且將產生json格式分出來讓notify_friend功能單一

app/controllers/goals_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class GoalsController < ApplicationController
before_action :authenticate_user!, only: [:new, :edit, :create, :update, :destroy]
before_action :find_params, only: [:edit, :update, :destroy, :complete]
+ after_action :notify_friend, only: [:update]

def notify_friend
SendEmailJob.perform_now(generate_json_params)
end

private
...略

def generate_json_params
mail_list = {}
count = 0
for email in @goal.shared_mails
mail_list[count.to_s] = email.mail_addr
count+=1
end

h = JSON.generate({ 'owner' => @goal.owner.name,
'goal' => @goal.title,
'complete_date' => @goal.complete_date,
'email' => mail_list })
end
end

[Rails] 使用sidekiq處理mailer寄信功能

上一篇完成動態新增email欄位後,我們使用sidekiq和mailer完成非同步寄信功能
sidekiq官方建議使用JSON作為active job的參數,此篇也記錄將Model的參數轉換成需要的json格式

先從gem的基本安裝開始,此範例使用’letter_opener’和’sidekiq’
letter_opener可讓我們即時檢視信件,而不必真的寄信到信箱裡
sidekiq則是幫助我們將寄信的動作放到背景執行

Gem Install

Gemfile
1
2
gem 'letter_opener', group: :development
gem 'sidekiq'

使用bundle安裝gem

接著安裝Redis,sidekiq需要它才能運行

1
brew install redis

Mailer

1
rails g mailer UserMailer

設定config

config/environments/development.rb
1
2
config.action_mailer.default_url_options = { host: "http://localhost:3000" }
config.action_mailer.delivery_method = :letter_opener

設定發信的信箱位址

app/mailers/application_mailer.rb
1
2
3
4
class ApplicationMailer < ActionMailer::Base
default from: "[email protected]"
layout 'mailer'
end

設定寄信的收件人和主旨,這裡我們還需要目標的擁有者和目標的標題。share_goal會在之後的ActiveJob使用

app/mailers/user_mailer.rb
1
2
3
4
5
6
7
8
9
10
class UserMailer < ApplicationMailer
def share_goal(owner, goal, complete_date, email)
@owner = owner
@goal = goal
@complete_date = complete_date
@email = email

mail(to: @email, subject: "[ResolutionTracker] #{@owner}分享目標")
end
end

接著設定信件的內容,這裡會使用share_goal傳進來的參數

app/views/user_mailer/share_goal.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<h1>ResolutionTracker</h1>

<p>
Hi
</p>

<p>
以下是<%= @owner %>的目標:
<ul>
<li>目標:<%= @goal%></li>
<li>預計完成日期:<%= @complete_date %></li>
</ul>
目標連結:<%= link_to @goal, "#" %>
</p>

<p>
請好好督促 <%= @owner %> 喔!
</p>

Controller

在controller中需要增加一個action將active record轉成json格式再傳給ActiveJob

app/controllers/goals_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class GoalsController < ApplicationController
before_action :find_params, only: [:edit, :update, :destroy, :complete, :notify_friend]
...略

def notify_friend
mail_list = {}
count = 0
for email in @goal.shared_mails
mail_list[count.to_s] = email.mail_addr
count+=1
end

h = JSON.generate({ 'owner' => @goal.owner.name,
'goal' => @goal.title,
'complete_date' => @goal.complete_date,
'email' => mail_list })
SendEmailJob.perform_now(h)
redirect_to goals_path
end

private

def find_params
@goal = current_user.goals.find(params[:id])
end
end

由於一個目標可能會分享給多位朋友,我們創立一個空的hash table儲存各email address
接著使用ruby的JSON.generate產生active job參數。
這裡提到為什麼使用簡單的格式而不是整個物件當參數,原因是這會讓Redis serializing,加重系統負擔

Active Job

設定active job初始化

config/initializers/active_job.rb
1
2
3
4
5
if Rails.env.test?
ActiveJob::Base.queue_adapter = :inline
else
ActiveJob::Base.queue_adapter = :sidekiq
end

在app資料夾底下新增jobs資料夾,並新增send_email_job.rb

app/jobs/send_email_job.rb
1
2
3
4
5
6
7
8
9
class SendEmailJob < ActiveJob::Base
queue_as :default

def perform(h)
t = JSON.load(h)
t['email'].each_value {|value|
UserMailer.share_goal(t['owner'], t['goal'], t['complete_date'], value).deliver}
end
end

這裡接收controller傳來的json參數後,將其轉換成hash table,並對每一筆email位址發送信件

驗證

要驗證前得分別啟動redis server和sidekiq

1
redis server

1
bundle exec sidekiq

螢幕快照 2016-04-14 下午3.27.10.png

參考來源:
Action Mailer Basics
blog參考文章
sidekiq github

[Rails] 使用nested_form動態新增輸入欄位

實作專案ResolutionTracker時,想增加分享目標給朋友的功能。
在填入email的頁面需要動態增加輸入欄位,讓使用者一次存入多筆資料
這裡介紹nested_form解決問題

Install Gem

Gemfile
1
gem 'nested_form'

然後使用bundle install安裝並重開server

Model

我們有二個model: goal, shared_mail,分別負責處理目標和使用者存入的mail address
在goal.rb需要能接受nested_attribute

app/models/goal.rb
1
2
3
4
5
6
7
8
class Goal < ActiveRecord::Base
validates :title, presence: true

has_many :shared_mails
belongs_to :owner, class_name: "User", foreign_key: :user_id

accepts_nested_attributes_for :shared_mails, allow_destroy: true
end
app/models/shared_mail.rb
1
2
3
class SharedMail < ActiveRecord::Base
belongs_to :goal
end

Controller

controller接受strong parameter時,得允許傳入shared_mails_attribute

app/controllers/goals_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class GoalsController < ApplicationController
#略...

def create
@goal = current_user.goals.create(goal_params)

if @goal.save
redirect_to goals_path
else
render 'new'
end
end

def update
if @goal.update(goal_params)
redirect_to goal_path
else
render 'edit'
end
end

private

def goal_params
params.require(:goal).permit(:title, :comment, :is_shareable,
:complete_date,
shared_mails_attributes: [:id, :mail_addr, :_destroy])
end
end

View

view的部分,將原來的form_for(或simple_form_for)改成nested_form_for

app/views/shared_mails/new.html.erb
1
2
3
4
5
6
7
8
9
10
11
<h2>分享目標</h2>

<%= nested_form_for @goal do |f| %>
<%= f.fields_for :shared_mails do |mail_form| %>
<%= mail_form.text_field :mail_addr %>
<%= mail_form.link_to_remove "Remove this mail address" %>
<% end %>

<%= f.link_to_add "Add a mail address", :shared_mails %>
<%= f.submit "提交", disable_with: "Submiting...", class: "btn btn-primary" %>
<% end %>