rails best practices
Post on 21-Apr-2017
124.623 Views
Preview:
TRANSCRIPT
Rails Best Practicesihower@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)
Agenda
• Concept: What’s good code?
• Move Code from Controller to Model
• RESTful best practices
• Model best practices
• Controller best practices
• View best practices
Your code become...
• 僵硬 (Rigidity):難以修改,每改一處牽一髮動全身
• 脆弱 (Fragility):一旦修改,別的無關地方也炸到
• 固定 (Immobility):難以分解,讓程式再重用
• 黏滯 (Viscosity):彈性不夠,把事情做對比做錯還難
• 不需要的複雜度 (Needless Complexity):過度設計沒直接好處的基礎設施
• 不需要的重複 (Needless Repetition):相同概念的程式碼被複製貼上重複使用
• 晦澀 (Opacity):難以閱讀,無法了解意圖
出自 Agile Software Development: Principles, Patterns, and Practices 一書
What’s Good code?
• Readability 易讀,容易了解
• Flexibility 彈性,容易擴充
• Effective 效率,撰碼快速
• Maintainability 維護性,容易找到問題
• Consistency 一致性,循慣例無需死背
• Testability 可測性,元件獨立容易測試
Best Practice Lesson 1:
Move code from Controller to Model
action code 超過15行請注意http://weblog.jamisbuck.org/2006/10/18/skinny-controller-fat-model
1.Move finder to named_scope
class PostsController < ApplicationController
def index @public_posts = Post.find(:all, :conditions => { :state => 'public' }, :limit => 10, :order => 'created_at desc')
@draft_posts = Post.find(:all, :conditions => { :state => 'draft' }, :limit => 10, :order => 'created_at desc') end
end
Before
class UsersController < ApplicationController
def index @published_post = Post.published @draft_post = Post.draft end
end
class Post < ActiveRecord::Base
named_scope :published, :conditions => { :state => 'published' }, :limit => 10, :order => 'created_at desc') named_scope :draft, :conditions => { :state => 'draft' }, :limit => 10, :order => 'created_at desc')
end
1.Move finder to named_scopeAfter
2. Use model association
class PostsController < ApplicationController
def create @post = Post.new(params[:post]) @post.user_id = current_user.id @post.save end
end
Before
class PostsController < ApplicationController
def create @post = current_user.posts.build(params[:post]) @post.save end
end
class User < ActiveRecord::Base has_many :postsend
2. Use model associationAfter
class PostsController < ApplicationController
def edit @post = Post.find(params[:id)
if @post.current_user != current_user flash[:warning] = 'Access denied' redirect_to posts_url end
end end
3. Use scope access不必要的權限檢查
Before
class PostsController < ApplicationController
def edit # raise RecordNotFound exception (404 error) if not found @post = current_user.posts.find(params[:id) end end
After3. Use scope access找不到自然會丟例外
4. Add model virtual attribute
<% form_for @user do |f| %> <%= text_filed_tag :full_name %><% end %>
class UsersController < ApplicationController
def create @user = User.new(params[:user) @user.first_name = params[:full_name].split(' ', 2).first @user.last_name = params[:full_name].split(' ', 2).last @user.save end end
Before
4. Add model virtual attributeclass User < ActiveRecord::Base def full_name [first_name, last_name].join(' ') end def full_name=(name) split = name.split(' ', 2) self.first_name = split.first self.last_name = split.last end end
example code from http://railscasts.com/episodes/16-virtual-attributes
After
<% form_for @user do |f| %> <%= f.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
5. Use model callback<% form_for @post do |f| %> <%= f.text_field :content %> <%= check_box_tag 'auto_tagging' %><% end %>
class PostController < ApplicationController
def create @post = Post.new(params[:post]) if params[:auto_tagging] == '1' @post.tags = AsiaSearch.generate_tags(@post.content) else @post.tags = "" end @post.save end end
Before
5. 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.tags = Asia.search(self.content) end end
After
<% form_for :note, ... do |f| %> <%= f.text_field :content %> <%= f.check_box :auto_tagging %><% end
class PostController < ApplicationController
def create @post = Post.new(params[:post]) @post.save end end
After
6. Replace Complex Creation with Factory Method
class InvoiceController < ApplicationController
def create @invoice = Invoice.new(params[:invoice]) @invoice.address = current_user.address @invoice.phone = current_user.phone @invoice.vip = ( @invoice.amount > 1000 ) if Time.now.day > 15 @invoice.delivery_time = Time.now + 2.month else @invoice.delivery_time = Time.now + 1.month end
@invoice.save end
end
Before
6. Replace Complex Creation with Factory Method
class Invoice < ActiveRecord::Base
def self.new_by_user(params, user) invoice = self.new(params) invoice.address = user.address invoice.phone = user.phone invoice.vip = ( invoice.amount > 1000 ) if Time.now.day > 15 invoice.delivery_time = Time.now + 2.month else invoice.delivery_time = Time.now + 1.month end end
end
After
class InvoiceController < ApplicationController def create @invoice = Invoice.new_by_user(params[:invoice], current_user) @invoice.save endend
After
7. Move Model Logic into the Model
class PostController < ApplicationController
def publish @post = Post.find(params[:id]) @post.update_attribute(:is_published, true) @post.approved_by = current_user if @post.create_at > Time.now - 7.days @post.popular = 100 else @post.popular = 0 end redirect_to post_url(@post) end end
Before
7. Move Model Logic into the Model
class Post < ActiveRecord::Base
def publish self.is_published = true self.approved_by = current_user if self.create_at > Time.now-7.days self.popular = 100 else self.popular = 0 end end end
After
class PostController < ApplicationController
def publish @post = Post.find(params[:id]) @post.publish redirect_to post_url(@post) end
end
After
8. model.collection_model_ids(many-to-many)
class User < ActiveRecord::Base
has_many :user_role_relationship has_many :roles, :through => :user_role_relationship end
class UserRoleRelationship < ActiveRecord::Base belongs_to :user belongs_to :roleend
class Role < ActiveRecord::Baseend
<% form_for @user do |f| %> <%= f.text_field :email %> <% for role in Role.all %> <%= check_box_tag 'role_id[]', role.id, @user.roles.include?(role) %> <%= role.name %> <% end %><% end %>
class User < ApplicationController
def update @user = User.find(params[:id]) if @user.update_attributes(params[:user]) @user.roles.delete_all (params[:role_id] || []).each { |i| @user.roles << Role.find(i) } end end end
Before
<% form_for @user do |f| %> <% for role in Role.all %> <%= check_box_tag 'user[role_ids][]', role.id, @user.roles.include?(role) %> <%= role.name %> <% end %> <%= hidden_field_tag 'user[role_ids][]', '' %>
<% end %>
class User < ApplicationController
def update @user = User.find(params[:id]) @user.update_attributes(params[:user]) # 相當於 @user.role_ids = params[:user][:role_ids] end end
After
Before9. Nested Model Forms (one-to-one)
class Product < ActiveRecord::Base has_one :detailend
class Detail < ActiveRecord::Base belongs_to :productend
<% form_for :product do |f| %> <%= f.text_field :title %> <% fields_for :detail do |detail| %> <%= detail.text_field :manufacturer %> <% end %>
<% end %>
class Product < ApplicationController def create @product = Product.new(params[:product]) @details = Detail.new(params[:detail]) Product.transaction do @product.save! @details.product = @product @details.save! end end end
example code from Agile Web Development with Rails 3rd.
Before
After9. Nested Model Forms (one-to-one)Rails 2.3 new feature
class Product < ActiveRecord::Base has_one :detail accepts_nested_attributes_for :detailend
<% form_for :product do |f| %> <%= f.text_field :title %> <% f.fields_for :detail do |detail| %> <%= detail.text_field :manufacturer %> <% end %> <% end
After
class Product < ApplicationController def create @product = Product.new(params[:product]) @product.save end end
10. Nested Model Forms (one-to-many)
class Project < ActiveRecord::Base has_many :tasks accepts_nested_attributes_for :tasksend
class Task < ActiveRecord::Base belongs_to :projectend
<% form_for @project do |f| %>
<%= f.text_field :name %>
<% f.fields_for :tasks do |tasks_form| %> <%= tasks_form.text_field :name %> <% end %>
<% end %>
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
Why RESTful?RESTful help you to organize/name controllers, routes
and actions in standardization way
class EventsController < ApplicationController
def index end def show end def create end def update end def destroy end
end
def watch_list end def add_favorite end def invite end def join end def leave end
def feeds end def add_comment end def show_comment end def destroy_comment end def edit_comment end def approve_comment end
def white_member_list end def black_member_list end def deny_user end def allow_user end def edit_managers end def set_user_as_manager end def set_user_as_member end
Before
After
class EventsController < ApplicationController def index; end def show; endend
class CommentsControlers < ApplicationController def index; end def create; end def destroy; end end
def FavoriteControllers < ApplicationController def create; end def destroy; endend
class EventMembershipsControlers < ApplicationController def create; end def destroy; endend
1. Overuse route customizations
map.resources :posts, :member => { :comments => :get, :create_comment => :post, :update_comment => :post, :delete_comment => :post }
Before
1. Overuse route customizationsFind another resources
map.resources :posts do |post| post.resources :comments end
After
Suppose we has a event model...
class Event < ActiveRecord::Base
has_many :attendee has_one :map
has_many :memberships has_many :users, :through => :memberships
end
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 designmy slide about restful:
http://www.slideshare.net/ihower/practical-rails2-350619
2. Needless deep nesting過度設計: Never more than one level
Before
map.resources :posts do |post| post.resources :comments do |comment| comment.resources :favorites end end
<%= link_to post_comment_favorite_path(@post, @comment, @favorite) %>
After
map.resources :posts do |post| post.resources :commentsend map.resources :comments do |comment| comment.resources :favoritesend
<%= link_to comment_favorite_path(@comment, @favorite) %>
2. Needless deep nesting過度設計: Never more than one level
3. Not use default routeBefore
map.resources :posts, :member => { :push => :post } map.connect ':controller/:action/:id' map.connect ':controller/:action/:id.:format'
3. Not use default routeAfter
map.resources :posts, :member => { :push => :post } #map.connect ':controller/:action/:id' #map.connect ':controller/:action/:id.:format'
map.connect 'special/:action/:id', :controller => 'special'
1. Keep Finders on Their Own Modelclass Post < ActiveRecord::Base has_many :comments def find_valid_comments self.comment.find(:all, :conditions => { :is_spam => false }, :limit => 10) end
end
class Comment < ActiveRecord::Base belongs_to :postend
class CommentsController < ApplicationController def index @comments = @post.find_valid_comments endend
Before
1. Keep Finders on Their Own Model
class Post < ActiveRecord::Base has_many :commentsend
class Comment < ActiveRecord::Base belongs_to :post named_scope :only_valid, :conditions => { :is_spam => false } named_scope :limit, lambda { |size| { :limit => size } }end
class CommentsController < ApplicationController def index @comments = @post.comments.only_valid.limit(10) endend
After
2. Love named_scopeclass PostController < ApplicationController
def search conditions = { :title => "%#{params[:title]}%" } if params[:title] conditions.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.merge!{ :is_published => true } end @posts = Post.find(:all, :conditions => conditions, :order => order, :limit => params[:limit]) end
end
Before
example code from Rails Antipatterns book
2. Love named_scopeAfter
class Post < ActiveRecord::Base
named_scope :matching, lambda { |column, value| return {} if value.blank? { :conditions => ["#{column} like ?", "%#{value}%"] } } named_scope :order, lambda { |order| { :order => case order when "title" : "title desc" when "created_at" : "created_at" end } } end
After
class PostController < ApplicationController
def search @posts = Post.matching(:title, params[:title]) .matching(:content, params[:content]) .order(params[:order]) end
end
3. the Law of Demeter
class Invoice < ActiveRecord::Base belongs_to :userend
<%= @invoice.user.name %><%= @invoice.user.address %><%= @invoice.user.cellphone %>
Before
3. the Law of Demeter
class Invoice < ActiveRecord::Base belongs_to :user delegate :name, :address, :cellphone, :to => :user, :prefix => trueend
<%= @invoice.user_name %><%= @invoice.user_address %><%= @invoice.user_cellphone %>
After
4. DRY: Metaprogrammingclass Post < ActiveRecord::Base
validate_inclusion_of :status, :in => ['draft', 'published', 'spam']
def self.all_draft find(:all, :conditions => { :status => 'draft' } end
def self.all_published find(:all, :conditions => { :status => 'published' } end def self.all_spam find(:all, :conditions => { :status => 'spam' } end def draft? self.stats == 'draft' end
def published? self.stats == 'published' end def spam? self.stats == 'spam' end end
Before
4. DRY: Metaprogrammingclass Post < ActiveRecord::Base
STATUSES = ['draft', 'published', 'spam'] validate_inclusion_of :status, :in => STATUSES
class << self STATUSES.each do |status_name| define_method "all_#{status}" do find(:all, :conditions => { :status => status_name } end end end
STATUSES.each do |status_name| define_method "#{status_name}?" do self.status == status_name end end
end
After
5. Extract into Module
class User < ActiveRecord::Base validates_presence_of :cellphone before_save :parse_cellphone
def parse_cellphone # do something end end
Before
# /lib/has_cellphone.rbmodule HasCellphone def self.included(base) base.validates_presence_of :cellphone base.before_save :parse_cellphone base.send(:include,InstanceMethods) base.send(:extend, ClassMethods) end module InstanceMethods def parse_cellphone # do something end end module ClassMethods end end
After
6. Extract to composed classBefore
# == 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.address_street && address_city == other_customer.address_city end end
After6. Extract to composed class(value object)
class Customer < ActiveRecord::Base composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ]end class Address attr_reader :street, :city def initialize(street, city) @street, @city = street, city end
def close_to?(other_address) city == other_address.city end
def ==(other_address) city == other_address.city && street == other_address.street endend
example code from Agile Web Development with Rails 3rd.
7. Use Observer
class Project < ActiveRecord::Base
after_create :send_create_notifications private def send_create_notifications self.members.each do |member| ProjectNotifier.deliver_notification(self, member) end end end
Before
class Project < ActiveRecord::Base # nothing hereend
# app/observers/project_notification_observer.rbclass ProjectNotificationObserver < ActiveRecord::Observer observe Project def after_create(project) project.members.each do |member| ProjectMailer.deliver_notice(project, member) end end
end
7. Use ObserverAfter
1. Isolating Seed DataBefore
class CreateRoles < ActiveRecord::Migration def self.up create_table "roles", :force => true do |t| t.string :name end ["admin", "author", "editor","account"].each do |name| Role.create!(:name => name) end end
def self.down drop_table "roles" endend
1. Isolating Seed DataAfter
# /db/seeds.rb (Rails 2.3.4)["admin", "author", "editor","account"].each do |name| Role.create!(:name => name)end
rake db:seed
After
# /lib/tasks/dev.rake (before Rails 2.3.4)
namespace :dev do
desc "Setup seed data" task :setup => :environment do ["admin", "author", "editor","account"].each do |name| Role.create!(:name => name) end end end
rake dev:setup
2. Always add DB index
class CreateComments < ActiveRecord::Migration def self.up create_table "comments", :force => true do |t| t.string :content t.integer :post_id t.integer :user_id end end
def self.down drop_table "comments" endend
Before
2. Always add DB indexclass CreateComments < ActiveRecord::Migration def self.up create_table "comments", :force => true do |t| t.string :content t.integer :post_id t.integer :user_id end
add_index :comments, :post_id add_index :comments, :user_id end
def self.down drop_table "comments" endend
After
1. Use before_filterclass PostController < ApplicationController
def show @post = current_user.posts.find(params[:id] end
def edit @post = current_user.posts.find(params[:id] end
def update @post = current_user.posts.find(params[:id] @post.update_attributes(params[:post]) end
def destroy @post = current_user.posts.find(params[:id] @post.destroy end end
Before
1. Use before_filterclass PostController < ApplicationController
before_filter :find_post, :only => [:show, :edit, :update, :destroy] def update @post.update_attributes(params[:post]) end
def destroy @post.destroy end protected def find_post @post = current_user.posts.find(params[:id]) end end
After
2. DRY Controllerclass PostController < ApplicationController
def index @posts = Post.all end
def show @post = Post.find(params[:id) end
def new @post = Post.new end
def create @post.create(params[:post] redirect_to post_path(@post) end
end
Before
def edit @post = Post.find(params[:id) end
def update @post = Post.find(params[:id) @post.update_attributes(params[:post]) redirect_to post_path(@post) end
def destroy @post = Post.find(params[:id) @post.destroy redirect_to posts_path end
After
2. DRY Controllerhttp://github.com/josevalim/inherited_resources
class PostController < InheritedResources::Base # magic!! nothing here!
end
After
2. DRY Controller
class PostController < InheritedResources::Base # if you need customize redirect url def create create! do |success, failure| seccess.html { redirect_to post_url(@post) } failure.html { redirect_to root_url } end end end
• You lose intent and readability
• Deviating from standards makes it harder to work with other programmers
• Upgrading rails
DRY Controller Debate!!小心走火入魔
from http://www.binarylogic.com/2009/10/06/discontinuing-resourcelogic/
1. Move code into controller
<% @posts = Post.find(:all) %><% @posts.each do |post| %> <%=h post.title %> <%=h post.content %><% end %>
Before
class PostsController < ApplicationController
def index @posts = Post.find(:all) end end
After
2. Move code into model
<% if current_user && (current_user == @post.user || @post.editors.include?(current_user) %> <%= link_to 'Edit this post', edit_post_url(@post) %><% end %>
<% if @post.editable_by?(current_user) %> <%= link_to 'Edit this post', edit_post_url(@post) %><% end %>
class Post < ActiveRecord::Base def ediable_by?(user) user && ( user == self.user || self.editors.include?(user) endend
Before
After
3. Move code into helper<%= select_tag :state, options_for_select( [[t(:draft),"draft" ], [t(:published),"published"]], params[:default_state] ) %>
Before
After
<%= select_tag :state, options_for_post_state(params[:default_state]) %>
# /app/helpers/posts_helper.rbdef options_for_post_state(default_state) options_for_select( [[t(:draft),"draft" ],[t(:published),"published"]], default_state )end
4. Replace instance variable with local variable
<%= render :partial => "sidebar" %>
<%= render :partial => "sidebar", :locals => { :post => @post } %>
Before
After
class Post < ApplicationController def show @post = Post.find(params[:id) endend
5. Use Form Builder<% form_for @post do |f| %>
<p> <%= f.label :title, t("post.title") %> <br> <%= f.text_field :title %> </p>
<p> <%= f.label :content %> <br> <%= f.text_area :content, :size => '80x20' %> </p> <p> <%= f.submit t("submit") %> </p>
<% end %>
Before
5. Use Form BuilderAfter
<% my_form_for @post do |f| %>
<%= f.text_field :title, :label => t("post.title") %> <%= f.text_area :content, :size => '80x20', :label => t("post.content") %>
<%= f.submit t("submit") %> <% end %>
module ApplicationHelper def my_form_for(*args, &block) options = args.extract_options!.merge(:builder => LabeledFormBuilder) form_for(*(args + [options]), &block) endend class MyFormBuilder < ActionView::Helpers::FormBuilder %w[text_field text_area].each do |method_name| define_method(method_name) do |field_name, *args| @template.content_tag(:p, field_label(field_name, *args) + "<br />" + field_error(field_name) + super) end end def submit(*args) @template.content_tag(:p, super) endend
After
6. Organize Helper files
# app/helpers/user_posts_helper.rb# app/helpers/author_posts_helper.rb# app/helpers/editor_posts_helper.rb# app/helpers/admin_posts_helper.rb
class ApplicationController < ActionController::Base helper :all # include all helpers, all the timeend
# app/helpers/posts_helper.rb
Before
After
7. Learn Rails Helpers
• Learn content_for and yield
• Learn how to pass block parameter in helper
• my slide about helper: http://www.slideshare.net/ihower/building-web-interface-on-rails
• Read Rails helpers source code
• /actionpack-x.y.z/action_view/helpers/*
Reference:參考網頁: http://weblog.jamisbuck.org/2006/10/18/skinny-controller-fat-model http://www.matthewpaulmoore.com/ruby-on-rails-code-quality-checklist http://www.chadfowler.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, Patterns, and Practices AWDwR 3rd The Rails Way 2nd. Advanced Rails Recipes Refactoring Ruby Edition Ruby Best Practices Enterprise Rails Rails Antipatterns Rails Rescue Handbook Code Review (PeepCode) Plugin Patterns (PeepCode)
More best practices:
• Rails Performancehttp://www.slideshare.net/ihower/rails-performance
• Rails Securityhttp://www.slideshare.net/ihower/rails-security-3299368
top related