rails best practices

99
Rails Best Practices [email protected] 張文鈿 2009/10 As this slide writing, the current Rails version is 2.3.4

Upload: wen-tien-chang

Post on 21-Apr-2017

124.623 views

Category:

Automotive


0 download

TRANSCRIPT

Rails Best [email protected]

張文鈿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 Taiwanhttp://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 團隊開發

Your code become...

• 僵硬 (Rigidity):難以修改,每改一處牽一髮動全身

• 脆弱 (Fragility):一旦修改,別的無關地方也炸到

• 固定 (Immobility):難以分解,讓程式再重用

• 黏滯 (Viscosity):彈性不夠,把事情做對比做錯還難

• 不需要的複雜度 (Needless Complexity):過度設計沒直接好處的基礎設施

• 不需要的重複 (Needless Repetition):相同概念的程式碼被複製貼上重複使用

• 晦澀 (Opacity):難以閱讀,無法了解意圖

出自 Agile Software Development: Principles, Patterns, and Practices 一書

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

What’s Good code?

• Readability 易讀,容易了解

• Flexibility 彈性,容易擴充

• Effective 效率,撰碼快速

• Maintainability 維護性,容易找到問題

• Consistency 一致性,循慣例無需死背

• Testability 可測性,元件獨立容易測試

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

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

Best Practice Lesson 2:

RESTful請愛用 RESTful conventions

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'

Best Practice Lesson 3:

Model

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

Breaking Up Models幫 Model 減重

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

class User < ActiveRecord::Base

include HasCellphone

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

Best Practice Lesson 4:

Migration

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

Best Practice Lesson 5:

Controller

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/

Best Practice Lesson 6:

View

最重要的守則:

Never logic code in Views

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/*

Best Practice Lesson 7:

Code Refactoring

We have Ruby edition now!! Must read it!

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

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