You are on page 1of 99

Rails Best Practices

ihower@gmail.com
張文鈿
2009/10

As this slide writing, the current Rails version is 2.3.4

Who am I ?

• 張文鈿 a.k.a. ihower
• http://ihower.tw
• http://twitter.com/ihower
• http://github.com/ihower
• 來自台灣新竹 (Hsinchu, Taiwan)

Ruby Taiwan
http://ruby.tw

Agenda • Concept: What’s good code? • Move Code from Controller to Model • RESTful best practices • Model best practices • Controller best practices • View best practices .

Warning! you should have testing before modify!
本次演講雖沒有提及測試,
但在修改重構程式前,
應有好的測試,
以確保程式於修改後執行無誤。

Best Practice Lesson 0:

Concepts

Why best practices?

• Large & complicated application
日漸複雜的程式
• Team & different coding style
團隊開發

• 僵硬 (Rigidity):難以修改,每改一處牽一髮動全身 • 脆弱 (Fragility):一旦修改,別的無關地方也炸到 • 固定 (Immobility):難以分解,讓程式再重用 • 黏滯 (Viscosity):彈性不夠,把事情做對比做錯還難 • 不需要的複雜度 (Needless Complexity):過度設計沒 直接好處的基礎設施 • 不需要的重複 (Needless Repetition):相同概念的程 式碼被複製貼上重複使用 • 晦澀 (Opacity):難以閱讀,無法了解意圖 出自 Agile Software Development: Principles. and Practices 一書 ... Your code become. Patterns.

We need good code: 我們需要好程式 .

What’s Good code? • Readability 易讀,容易了解 • Flexibility 彈性,容易擴充 • Effective 效率,撰碼快速 • Maintainability 維護性,容易找到問題 • Consistency 一致性,循慣例無需死背 • Testability 可測性,元件獨立容易測試 .

So. What we can do? 來開始學幾招吧 .

org/2006/10/18/skinny-controller-fat-model .jamisbuck. Best Practice Lesson 1: Move code from Controller to Model action code 超過15行請注意 http://weblog.

:order => 'created_at desc') @draft_posts = Post. :limit => 10.find(:all. Before 1. :order => 'created_at desc') end end . :conditions => { :state => 'public' }.Move finder to named_scope class PostsController < ApplicationController def index @public_posts = Post. :limit => 10.find(:all. :conditions => { :state => 'draft' }.

:conditions => { :state => 'published' }. :order => 'created_at desc') named_scope :draft.Move finder to named_scope class UsersController < ApplicationController def index @published_post = Post. :order => 'created_at desc') end . After 1.draft end end class Post < ActiveRecord::Base named_scope :published. :limit => 10.published @draft_post = Post. :limit => 10. :conditions => { :state => 'draft' }.

Use model association class PostsController < ApplicationController def create @post = Post. Before 2.user_id = current_user.id @post.save end end .new(params[:post]) @post.

After 2.save end end class User < ActiveRecord::Base has_many :posts end . Use model association class PostsController < ApplicationController def create @post = current_user.posts.build(params[:post]) @post.

current_user != current_user flash[:warning] = 'Access denied' redirect_to posts_url end end end . Use scope access Before 不必要的權限檢查 class PostsController < ApplicationController def edit @post = Post.find(params[:id) if @post. 3.

3. Use scope access After 找不到自然會丟例外 class PostsController < ApplicationController def edit # raise RecordNotFound exception (404 error) if not found @post = current_user.posts.find(params[:id) end end .

Before 4.last_name = params[:full_name].split(' '.first @user. 2). 2).first_name = params[:full_name].last @user. Add model virtual attribute <% form_for @user do |f| %> <%= text_filed_tag :full_name %> <% end %> class UsersController < ApplicationController def create @user = User.save end end .new(params[:user) @user.split(' '.

split(' '. 2) self.com/episodes/16-virtual-attributes .last end end example code from http://railscasts.join(' ') end def full_name=(name) split = name.first_name = split. Add model virtual attribute class User < ActiveRecord::Base def full_name [first_name. After 4.first self.last_name = split. last_name].

text_field :full_name %> <% end %> class UsersController < ApplicationController def create @user = User.create(params[:user) end end example code from http://railscasts.com/episodes/16-virtual-attributes . After <% form_for @user do |f| %> <%= f.

Use model callback Before <% form_for @post do |f| %> <%= f.tags = AsiaSearch.generate_tags(@post.content) else @post.new(params[:post]) if params[:auto_tagging] == '1' @post.tags = "" end @post. 5.text_field :content %> <%= check_box_tag 'auto_tagging' %> <% end %> class PostController < ApplicationController def create @post = Post.save end end .

tags = Asia.content) end end . Use model callback class Post < ActiveRecord::Base attr_accessor :auto_tagging before_save :generate_taggings private def generate_taggings return unless auto_tagging == '1' self.search(self. After 5.

. do |f| %> <%= f.. After <% form_for :note.text_field :content %> <%= f.save end end .new(params[:post]) @post..check_box :auto_tagging %> <% end class PostController < ApplicationController def create @post = Post.

month else @invoice.delivery_time = Time. Replace Complex Creation Before with Factory Method class InvoiceController < ApplicationController def create @invoice = Invoice.now.delivery_time = Time.day > 15 @invoice.6.address = current_user.now + 1.address @invoice.save end end .now + 2.amount > 1000 ) if Time.vip = ( @invoice.phone = current_user.new(params[:invoice]) @invoice.month end @invoice.phone @invoice.

6.address = user.delivery_time = Time.now + 2.day > 15 invoice.amount > 1000 ) if Time.phone = user.now.new_by_user(params.delivery_time = Time.vip = ( invoice.address invoice.new(params) invoice. user) invoice = self.now + 1. Replace Complex Creation After with Factory Method class Invoice < ActiveRecord::Base def self.phone invoice.month else invoice.month end end end .

current_user) @invoice.save end end .new_by_user(params[:invoice]. After class InvoiceController < ApplicationController def create @invoice = Invoice.

update_attribute(:is_published.now . Move Model Logic into the Before Model class PostController < ApplicationController def publish @post = Post.create_at > Time.approved_by = current_user if @post.7.7. true) @post.find(params[:id]) @post.popular = 100 else @post.days @post.popular = 0 end redirect_to post_url(@post) end end .

now-7.7.approved_by = current_user if self.popular = 0 end end end .create_at > Time.days self. Move Model Logic into the After Model class Post < ActiveRecord::Base def publish self.popular = 100 else self.is_published = true self.

After class PostController < ApplicationController def publish @post = Post.find(params[:id]) @post.publish redirect_to post_url(@post) end end .

:through => :user_role_relationship end class UserRoleRelationship < ActiveRecord::Base belongs_to :user belongs_to :role end class Role < ActiveRecord::Base end .collection_model_ids (many-to-many) class User < ActiveRecord::Base has_many :user_role_relationship has_many :roles. model. 8.

roles. role.delete_all (params[:role_id] || []).roles << Role.name %> <% end %> <% end %> class User < ApplicationController def update @user = User.each { |i| @user.update_attributes(params[:user]) @user.find(params[:id]) if @user.id.find(i) } end end end . Before <% form_for @user do |f| %> <%= f. @user.all %> <%= check_box_tag 'role_id[]'.include?(role) %> <%= role.text_field :email %> <% for role in Role.roles.

After <% form_for @user do |f| %> <% for role in Role.roles.include?(role) <%= role.update_attributes(params[:user]) # 相當於 @user.role_ids = params[:user][:role_ids] end end .name %> <% end %> <%= hidden_field_tag 'user[role_ids][]'. @user.find(params[:id]) @user. role. '' %> <% end %> class User < ApplicationController def update @user = User.all %> <%= check_box_tag 'user[role_ids][]'.id.

Nested Model Forms (one-to-one) Before class Product < ActiveRecord::Base has_one :detail end class Detail < ActiveRecord::Base belongs_to :product end <% form_for :product do |f| %> <%= f.text_field :manufacturer %> <% end %> <% end %> .text_field :title %> <% fields_for :detail do |detail| %> <%= detail.9.

save! end end end example code from Agile Web Development with Rails 3rd.product = @product @details.transaction do @product.new(params[:product]) @details = Detail. Before class Product < ApplicationController def create @product = Product. .new(params[:detail]) Product.save! @details.

fields_for :detail do |detail| %> <%= detail.text_field :manufacturer %> <% end %> <% end .9.3 new feature class Product < ActiveRecord::Base has_one :detail accepts_nested_attributes_for :detail end <% form_for :product do |f| %> <%= f. Nested Model Forms (one-to-one) After Rails 2.text_field :title %> <% f.

new(params[:product]) @product. After class Product < ApplicationController def create @product = Product.save end end .

10. Nested Model Forms (one-to-many) class Project < ActiveRecord::Base has_many :tasks accepts_nested_attributes_for :tasks end class Task < ActiveRecord::Base belongs_to :project end <% form_for @project do |f| %> <%= f.fields_for :tasks do |tasks_form| %> <%= tasks_form.text_field :name %> <% end %> <% end %> .text_field :name %> <% f.

Nested Model Forms
before Rails 2.3 ?

• Ryan Bates’s series of railscasts on complex forms
• http://railscasts.com/episodes/75-complex-forms-part-3

• Recipe 13 in Advanced Rails Recipes book

Best Practice Lesson 2:

RESTful
請愛用 RESTful conventions

Why RESTful?
RESTful help you to organize/name controllers, routes
and actions in standardization way

Before class EventsController < ApplicationController def index def white_member_list def watch_list def feeds end end end end def show def black_member_list def add_favorite def add_comment end end end end def create def deny_user def invite def show_comment end end end end def update def allow_user def join def destroy_comment end end end end def destroy def edit_managers def leave def edit_comment end end end end def approve_comment def set_user_as_manager end end def set_user_as_member end end .

end def destroy. end end class EventMembershipsControlers < ApplicationController def create. end end class CommentsControlers < ApplicationController def index. end def show. end end def FavoriteControllers < ApplicationController def create. After class EventsController < ApplicationController def index. end def create. end end . end def destroy. end def destroy.

resources :posts. :update_comment => :post. :delete_comment => :post } . Overuse route customizations map. :member => { :comments => :get. :create_comment => :post. Before 1.

Overuse route customizations Find another resources map.resources :comments end .resources :posts do |post| post. After 1.

:through => :memberships end .. class Event < ActiveRecord::Base has_many :attendee has_one :map has_many :memberships has_many :users..Suppose we has a event model.

Can you answer how to design your resources ? • manage event attendees (one-to-many) • manage event map (one-to-one) • manage event memberships (many-to-many) • operate event state: open or closed • search events • sorting events • event admin interface .

Learn RESTful design my slide about restful: http://www.slideshare.net/ihower/practical-rails2-350619 .

resources :comments do |comment| comment. Before 2. Needless deep nesting 過度設計: Never more than one level map. @comment.resources :favorites end end <%= link_to post_comment_favorite_path(@post. @favorite) %> .resources :posts do |post| post.

@favorite) %> .resources :posts do |post| post. Needless deep nesting 過度設計: Never more than one level map.resources :favorites end <%= link_to comment_favorite_path(@comment.resources :comments do |comment| comment. After 2.resources :comments end map.

:format' . Not use default route map.connect ':controller/:action/:id. Before 3. :member => { :push => :post } map.connect ':controller/:action/:id' map.resources :posts.

After 3.connect ':controller/:action/:id' #map. Not use default route map.resources :posts.connect 'special/:action/:id'. :member => { :push => :post } #map.:format' map. :controller => 'special' .connect ':controller/:action/:id.

Best Practice Lesson 3: Model .

find_valid_comments end end . :conditions => { :is_spam => false }. Before 1.find(:all.comment. :limit => 10) end end class Comment < ActiveRecord::Base belongs_to :post end class CommentsController < ApplicationController def index @comments = @post. Keep Finders on Their Own Model class Post < ActiveRecord::Base has_many :comments def find_valid_comments self.

comments.only_valid. Keep Finders on Their Own Model class Post < ActiveRecord::Base has_many :comments end class Comment < ActiveRecord::Base belongs_to :post named_scope :only_valid.limit(10) end end . After 1. lambda { |size| { :limit => size } } end class CommentsController < ApplicationController def index @comments = @post. :conditions => { :is_spam => false } named_scope :limit.

find(:all. :conditions => conditions.merge!{ :is_published => true } end @posts = Post.merge!{ :content => "%#{params[:content]}%" } if params[:content] case params[:order] when "title" : order = "title desc" when "created_at" : order = "created_at" end if params[:is_published] conditions. :order => order. Before 2. Love named_scope class PostController < ApplicationController def search conditions = { :title => "%#{params[:title]}%" } if params[:title] conditions. :limit => params[:limit]) end end example code from Rails Antipatterns book .

lambda { |order| { :order => case order when "title" : "title desc" when "created_at" : "created_at" end } } end . lambda { |column. "%#{value}%"] } } named_scope :order.blank? { :conditions => ["#{column} like ?". value| return {} if value. After 2. Love named_scope class Post < ActiveRecord::Base named_scope :matching.

After class PostController < ApplicationController def search @posts = Post.order(params[:order]) end end . params[:content]) .matching(:title. params[:title]) .matching(:content.

cellphone %> .user.address %> <%= @invoice. the Law of Demeter class Invoice < ActiveRecord::Base belongs_to :user end <%= @invoice. Before 3.user.name %> <%= @invoice.user.

user_cellphone %> . After 3.user_name %> <%= @invoice. :address. :to => :user. :prefix => true end <%= @invoice. :cellphone. the Law of Demeter class Invoice < ActiveRecord::Base belongs_to :user delegate :name.user_address %> <%= @invoice.

stats == 'draft' end def published? self.stats == 'spam' end end . DRY: Metaprogramming Before class Post < ActiveRecord::Base validate_inclusion_of :status. :conditions => { :status => 'published' } end def self.4. 'spam'] def self. :conditions => { :status => 'spam' } end def draft? self.all_draft find(:all. :conditions => { :status => 'draft' } end def self. 'published'.stats == 'published' end def spam? self.all_spam find(:all. :in => ['draft'.all_published find(:all.

'spam'] validate_inclusion_of :status. :conditions => { :status => status_name } end end end STATUSES. 4.each do |status_name| define_method "#{status_name}?" do self. DRY: Metaprogramming After class Post < ActiveRecord::Base STATUSES = ['draft'.status == status_name end end end .each do |status_name| define_method "all_#{status}" do find(:all. :in => STATUSES class << self STATUSES. 'published'.

Breaking Up Models 幫 Model 減重 .

Before 5. Extract into Module class User < ActiveRecord::Base validates_presence_of :cellphone before_save :parse_cellphone def parse_cellphone # do something end end .

included(base) base.send(:include.InstanceMethods) base.validates_presence_of :cellphone base. After # /lib/has_cellphone.send(:extend. ClassMethods) end module InstanceMethods def parse_cellphone # do something end end module ClassMethods end end .rb module HasCellphone def self.before_save :parse_cellphone base.

After class User < ActiveRecord::Base include HasCellphone end .

address_city end end .address_street && address_city == other_customer. Extract to composed class # == Schema Information # address_city :string(255) # address_street :string(255) class Customer < ActiveRecord::Base def adddress_close_to?(other_customer) address_city == other_customer.address_city end def address_equal(other_customer) address_street == other_customer. Before 6.

. @city = street. city end def close_to?(other_address) city == other_address. %w(address_city city) ] end class Address attr_reader :street.street end end example code from Agile Web Development with Rails 3rd. Extract to composed class After (value object) class Customer < ActiveRecord::Base composed_of :address.city && street == other_address. city) @street. :mapping => [ %w(address_street street). :city def initialize(street. 6.city end def ==(other_address) city == other_address.

each do |member| ProjectNotifier.deliver_notification(self. Use Observer class Project < ActiveRecord::Base after_create :send_create_notifications private def send_create_notifications self.members. Before 7. member) end end end .

After 7.rb class ProjectNotificationObserver < ActiveRecord::Observer observe Project def after_create(project) project.members. Use Observer class Project < ActiveRecord::Base # nothing here end # app/observers/project_notification_observer. member) end end end .deliver_notice(project.each do |member| ProjectMailer.

Best Practice Lesson 4: Migration .

Isolating Seed Data class CreateRoles < ActiveRecord::Migration def self.up create_table "roles"."account"].down drop_table "roles" end end . Before 1.create!(:name => name) end end def self. "editor". :force => true do |t| t.each do |name| Role. "author".string :name end ["admin".

4) ["admin". Isolating Seed Data # /db/seeds.create!(:name => name) end rake db:seed ."account"]. After 1. "author". "editor".3.each do |name| Role.rb (Rails 2.

"author". "editor".create!(:name => name) end end end rake dev:setup .each do |name| Role.rake (before Rails 2.3.4) namespace :dev do desc "Setup seed data" task :setup => :environment do ["admin". After # /lib/tasks/dev."account"].

integer :user_id end end def self.string :content t.up create_table "comments".integer :post_id t. Before 2. Always add DB index class CreateComments < ActiveRecord::Migration def self.down drop_table "comments" end end . :force => true do |t| t.

:user_id end def self. :post_id add_index :comments. Always add DB index class CreateComments < ActiveRecord::Migration def self.integer :post_id t.string :content t.up create_table "comments". :force => true do |t| t.integer :user_id end add_index :comments.down drop_table "comments" end end . After 2.

Best Practice Lesson 5: Controller .

find(params[:id] end def update @post = current_user.posts.find(params[:id] @post. 1.destroy end end .find(params[:id] @post.update_attributes(params[:post]) end def destroy @post = current_user.posts.find(params[:id] end def edit @post = current_user.posts. Use before_filter Before class PostController < ApplicationController def show @post = current_user.posts.

Use before_filter After class PostController < ApplicationController before_filter :find_post. 1.destroy end protected def find_post @post = current_user.posts. :edit. :only => [:show. :destroy] def update @post. :update.update_attributes(params[:post]) end def destroy @post.find(params[:id]) end end .

all @post = Post.create(params[:post] redirect_to posts_path redirect_to post_path(@post) end end end .find(params[:id) def create @post.destroy @post.update_attributes(params[:post]) redirect_to post_path(@post) def new end @post = Post.find(params[:id) @post = Post.find(params[:id) end @post. Before 2. DRY Controller class PostController < ApplicationController def index def edit @posts = Post.new end def destroy @post = Post.find(params[:id) end end def show def update @post = Post.

DRY Controller http://github.com/josevalim/inherited_resources class PostController < InheritedResources::Base # magic!! nothing here! end . After 2.

failure| seccess. After 2.html { redirect_to root_url } end end end .html { redirect_to post_url(@post) } failure. DRY Controller class PostController < InheritedResources::Base # if you need customize redirect url def create create! do |success.

binarylogic.com/2009/10/06/discontinuing-resourcelogic/ .DRY Controller Debate!! 小心走火入魔 • You lose intent and readability • Deviating from standards makes it harder to work with other programmers • Upgrading rails from http://www.

Best Practice Lesson 6: View .

最重要的守則: Never logic code in Views .

title %> <%=h post.find(:all) end end .content %> <% end %> After class PostsController < ApplicationController def index @posts = Post.find(:all) %> <% @posts. Move code into controller Before <% @posts = Post.each do |post| %> <%=h post. 1.

edit_post_url(@post) %> <% end %> class Post < ActiveRecord::Base def ediable_by?(user) user && ( user == self.user || @post.include?(user) end end .include?(current_user) %> <%= link_to 'Edit this post'.editable_by?(current_user) %> After <%= link_to 'Edit this post'.editors. Move code into model Before <% if current_user && (current_user == @post. edit_post_url(@post) %> <% end %> <% if @post.editors. 2.user || self.

"draft" ]."draft" ]. Move code into helper <%= select_tag :state.[t(:published)."published"]]. [t(:published).rb def options_for_post_state(default_state) options_for_select( [[t(:draft)."published"]]. options_for_post_state(params[:default_state]) %> # /app/helpers/posts_helper. Before 3. default_state ) end . options_for_select( [[t(:draft). params[:default_state] ) %> After <%= select_tag :state.

:locals => { :post => @post } %> .find(params[:id) end end Before <%= render :partial => "sidebar" %> After <%= render :partial => "sidebar". 4. Replace instance variable with local variable class Post < ApplicationController def show @post = Post.

title") %> <br> <%= f. t("post. Use Form Builder <% form_for @post do |f| %> <p> <%= f.label :content %> <br> <%= f.label :title.text_field :title %> </p> <p> <%= f. Before 5. :size => '80x20' %> </p> <p> <%= f.text_area :content.submit t("submit") %> </p> <% end %> .

After 5. :label => t("post.content") %> <%= f. :size => '80x20'. :label => t("post.submit t("submit") %> <% end %> . Use Form Builder <% my_form_for @post do |f| %> <%= f.text_area :content.text_field :title.title") %> <%= f.

&block) end end class MyFormBuilder < ActionView::Helpers::FormBuilder %w[text_field text_area].merge(:builder => LabeledFormBuilder) form_for(*(args + [options]). field_label(field_name. *args) + "<br />" + field_error(field_name) + super) end end def submit(*args) @template. *args| @template. super) end end . &block) options = args.content_tag(:p.each do |method_name| define_method(method_name) do |field_name.extract_options!.content_tag(:p. After module ApplicationHelper def my_form_for(*args.

6.rb . Organize Helper files # app/helpers/user_posts_helper.rb # app/helpers/editor_posts_helper.rb class ApplicationController < ActionController::Base After helper :all # include all helpers.rb # app/helpers/admin_posts_helper.rb Before # app/helpers/author_posts_helper. all the time end # app/helpers/posts_helper.

Learn Rails Helpers • Learn content_for and yield • Learn how to pass block parameter in helper • my slide about helper: http://www.z/action_view/helpers/* .slideshare.net/ihower/building-web-interface-on-rails • Read Rails helpers source code • /actionpack-x. 7.y.

Best Practice Lesson 7: Code Refactoring .

We have Ruby edition now!! Must read it! .

com/ruby-on-rails-code-quality-checklist http://www.matthewpaulmoore. Advanced Rails Recipes Refactoring Ruby Edition Ruby Best Practices Enterprise Rails Rails Antipatterns Rails Rescue Handbook Code Review (PeepCode) Plugin Patterns (PeepCode) .org/2006/10/18/skinny-controller-fat-model http://www. Reference: 參考網頁: http://weblog.chadfowler.jamisbuck. Patterns.com/2009/4/1/20-rails-development-no-no-s 參考資料: Pragmatic Patterns of Ruby on Rails 大場寧子 Advanced Active Record Techniques Best Practice Refactoring Chad Pytel Refactoring Your Rails Application RailsConf 2008 The Worst Rails Code You've Ever Seen Obie Fernandez Mastering Rails Forms screencasts with Ryan Bates 參考書籍: Agile Software Development: Principles. and Practices AWDwR 3rd The Rails Way 2nd.

slideshare. More best practices: • Rails Performance http://www.slideshare.net/ihower/rails-performance • Rails Security http://www.net/ihower/rails-security-3299368 .

感謝聆聽,請多指教。 Thank you. .