RESTful 应用实作

The most depressing thing about life as a programmer, I think, is if you’re faced with a chunk of code that either someone else wrote or, worse still, you wrote yourself but you no longer dare to modify. That’s depressing. - Peyton Jones

请注意本章内容衔接前三章,请先完成前三章内容。

我们在上一章学习了数据库关连,那么要如何搭配 RESTful 路由设计 controller 及表单呢?我们在这里将综合前几章所学,来实作各种形式的 Resource 应用。

一对多 Resources

范例一: 设计一个 event has_many :attendees

延续上一章一对多关联建立好的Attendee,我们希望实作浏览个别 event 有哪些 attendees, 并可以 CRUD。请修改 config/routes.rb 为

  1. resources :events do
  2. resources :attendees, :controller => 'event_attendees'
  3. end

执行以下指令产生 controller 档案

  1. rails g controller event_attendees

编辑 app/controllers/event_attendees_controller.rb,插入如下内容:

  1. before_action :find_event
  2. def index
  3. @attendees = @event.attendees
  4. end
  5. def show
  6. @attendee = @event.attendees.find( params[:id] )
  7. end
  8. def new
  9. @attendee = @event.attendees.build
  10. end
  11. def create
  12. @attendee = @event.attendees.build( attendee_params )
  13. if @attendee.save
  14. redirect_to event_attendees_url( @event )
  15. else
  16. render :action => :new
  17. end
  18. end
  19. def edit
  20. @attendee = @event.attendees.find( params[:id] )
  21. end
  22. def update
  23. @attendee = @event.attendees.find( params[:id] )
  24. if @attendee.update( attendee_params )
  25. redirect_to event_attendees_url( @event )
  26. else
  27. render :action => :edit
  28. end
  29. end
  30. def destroy
  31. @attendee = @event.attendees.find( params[:id] )
  32. @attendee.destroy
  33. redirect_to event_attendees_url( @event )
  34. end
  35. protected
  36. def find_event
  37. @event = Event.find( params[:event_id] )
  38. end
  39. def attendee_params
  40. params.require(:attendee).permit(:name)
  41. end

编辑 app/views/events/index.html.erb,在循环中加入

  1. <%= link_to 'attendees', event_attendees_path(event) %>

编辑 app/views/event_attendees/index.html.erb

  1. <ul>
  2. <% @attendees.each do |attendee| %>
  3. <li>
  4. <%= attendee.name %>
  5. <%= link_to 'show', event_attendee_path(@event, attendee) %>
  6. <%= link_to 'edit', edit_event_attendee_path(@event, attendee) %>
  7. <%= link_to 'destroy', event_attendee_path(@event, attendee),
  8. :method => :delete %>
  9. </li>
  10. <% end %>
  11. </ul>
  12. <%= link_to 'new attendee', new_event_attendee_path(@event) %>

编辑 app/views/event_attendees/show.html.erb

  1. <p><%= @attendee.name %> </p>

编辑 app/views/event_attendees/new.html.erb

  1. <%= form_for @attendee, :url => event_attendees_path(@event) do |f| %>
  2. <%= f.text_field :name %>
  3. <%= f.submit %>
  4. <% end %>

编辑 app/views/event_attendees/edit.html.erb

  1. <%= form_for @attendee, :url => event_attendee_path(@event, @attendee), :html => { :method => :patch } do |f| %>
  2. <%= f.text_field :name %>
  3. <%= f.submit %>
  4. <% end %>

范例二: 让 event 可以用 select 单选一个 category

另一种常见的1对多用法,则是用下拉选单。延续上一章建好的Category model,让我们编辑 app/models/event.rb 加上关连:

  1. class Event < ApplicationRecord
  2. belongs_to :category
  3. end

编辑 app/models/category.rb 加上关连

  1. class Category < ApplicationRecord
  2. has_many :events
  3. end

首先,我们需要先建立一些 Category 的资料,进入 rails console 输入:

  1. Category.create( :name => "Course" )
  2. Category.create( :name => "Meeting" )
  3. Category.create( :name => "Conference" )

接着编辑 app/views/events/_form.html.erb 这个样板,让我们来加上一个下拉选单。在表单中加入:

  1. <%= f.collection_select(:category_id, Category.all, :id, :name) %>

或是用以下的写法,效果是一样的:

  1. <%= f.select :category_id, Category.all.map{ |c| [c.name, c.id] } %>

然后修改EventsControllerevent_params好可以接收到category_id参数。这样资料就会存进数据库了。

  1. def event_params
  2. params.require(:event).permit(:name, :description, :category_id)
  3. end

如此就会出现下拉选单了。让我们来修改 app/views/events/show.html.erb 可以显示出 category 的名字:

  1. <p>Category: <%= @event.category.name %></p>

不过 @event.category 可能是 nil,这会导致 nil.name 发生错误。一个简单的方式是改使用 @event.category.try(:name),另一招则是在 Event model 加入以下程式,就会有 @event.category_name 可以使用,而且允许 @event.category 是 nil

  1. delegate :name, :to => :category, :prefix => true, :allow_nil => true

如此便完成了。

一对一 Resource

案例一:建立Location表单

延续上一章新增的一对一的关系Location Model,也就是一个Location属于一个Event。我们来建立一个表单接口可以编辑Location

执行以下指令产生 controller 档案

  1. rails g controller event_locations

编辑config/routes.rb加上一个Singular Resource,因为一个Event只有一个Location,所以我们使用了单数Resource

  1. resources :events do
  2. resource :location, :controller => 'event_locations'
  3. end

注意到我们的Controller档名还是复数,使用RESTful路由的Controller,无论在config/routes.rb中使用单数resource或复数resources形式,档名一律都是复数。

编辑app/controllers/event_locations_controller.rb:

  1. class EventLocationsController < ApplicationController
  2. before_action :find_event
  3. def show
  4. @location = @event.location
  5. end
  6. def new
  7. @location = @event.build_location
  8. end
  9. def create
  10. @location = @event.build_location( location_params )
  11. if @location.save
  12. redirect_to event_location_url( @event )
  13. else
  14. render :action => :new
  15. end
  16. end
  17. def edit
  18. @location = @event.location
  19. end
  20. def update
  21. @location = @event.location
  22. if @location.update( location_params )
  23. redirect_to event_location_url( @event )
  24. else
  25. render :action => :edit
  26. end
  27. end
  28. def destroy
  29. @location = @event.location
  30. @location.destroy
  31. redirect_to event_location_url( @event )
  32. end
  33. protected
  34. def find_event
  35. @event = Event.find( params[:event_id] )
  36. end
  37. def location_params
  38. params.require(:location).permit(:name)
  39. end
  40. end

因为是单数resource的关系,所以就没有index这个Action了,也没有event_locations_pathevent_location_path(event, location)这种路由方法。

编辑app/views/events/index.html.erb,在循环中加入

  1. <%= link_to 'location', event_location_path(event) %>

编辑app/views/event_locations/show.html.erb

  1. <h1><%= @event.name %></h1>
  2. <% if @event.location %>
  3. <p><%= @event.location.name %></p>
  4. <p><%= link_to "edit", edit_event_location_path(@event) %></p>
  5. <p><%= link_to "destroy", event_location_path(@event), :method => :delete %></p>
  6. <% else %>
  7. <p>N/A</p>
  8. <p><%= link_to "Add location", new_event_location_path(@event) %></p>
  9. <% end %>

编辑app/views/event_locations/new.html.erb

  1. <%= form_for @location, :url => event_location_path(@event) do |f| %>
  2. <%= f.text_field :name %>
  3. <%= f.submit %>
  4. <% end %>

编辑app/views/event_locations/edit.html.erb

  1. <%= form_for @location, :url => event_location_path(@event), :method => :patch do |f| %>
  2. <%= f.text_field :name %>
  3. <%= f.submit %>
  4. <% end %>

案例二:用 Nested Model 顺带编辑跟新增

由于LocationEvent是一对一关系,可以说LocationEvent的附属资料。因此我们也可以将Location的表单直接做在Event的表单里,这样Location甚至不需要自己的Controller了:

编辑app/models/event.rb加上:

  1. accepts_nested_attributes_for :location, :allow_destroy => true, :reject_if => :all_blank

acceptsnested_attributes_for这个方法可以让更新_event资料时,也可以直接更新location的关联资料。也就是说,我们可以完全不需要修改events_controller的新增和编辑Action,就可以透过本来的params[:event]参数来新增或修改location了。这里有两个特别的参数,:allowdestroy是说我们可以在表单中多放一个_destroy核选块来表示删除,而:reject_if表示说在什么条件下,就当做没有要真的动作,例如:all_blank就表示如果资料都是空的,就不建立_location资料(当然也就不会检查location的验证了)。这是因为虽然要显示location表单,但是不表示使用者一定要输入。有输入就表示必须通过Location Model的资料验证。

编辑app/views/events/_form.html.erb加上Location的表单,这里使用了fields_for来达成嵌套表单:

  1. <%= f.fields_for :location do |location_form| %>
  2. <p>
  3. <%= location_form.label :name, "Location Name" %>
  4. <%= location_form.text_field :name %>
  5. <% unless location_form.object.new_record? %>
  6. <%= location_form.label :_destroy, 'Remove:' %>
  7. <%= location_form.check_box :_destroy %>
  8. <% end %>
  9. </p>
  10. <% end %>

编辑app/helpers/events_helper.rb新增一个Helper

  1. def setup_event(event)
  2. event.build_location unless event.location
  3. event
  4. end

我们会用setupevent(@event)来置换form_for中的@event,这是因为如果@event.locationnil的话,_Location表单就完全不会显示,所以假如没有,就需要预先build_location给它。

编辑app/views/events/new.html.erb

  1. <%= form_for setup_event(@event), :url => events_path do |f| %>

编辑app/views/events/edit.html.erb

  1. <%= form_for setup_event(@event), :url => event_path(@event), :method => :put do |f| %>

最后记得修改EventsControllerevent_params好可以接收到location参数

  1. def event_params
  2. params.require(:event).permit(:name, :description, :category_id, :location_attributes => [:id, :name, :_destroy] )
  3. end

多对多 Resources

上一章中,我们也新增了EventGroupShip这个Model作为EventGroup之间的Joining table,那么要怎么设计表单呢?

案例: 在 event new/edit 中, 可以使用 checkbox 多选 group

最常见的方式就是提供check_box核选方块让使用者可以勾选了,此例中我们打算在event的表单中放入group清单来做勾选。

编辑app/views/events/_form.html.erb

  1. <%= f.collection_check_boxes(:group_ids, Group.all, :id, :name) %>

或是用以下的写法,效果是一样的:

  1. <% @groups.each do |g| %>
  2. <%= check_box_tag "event[group_ids][]", g.id, @event.groups.map(&:id).include?(g.id) %> <%= g.name %>
  3. <% end %>
  4. <%= hidden_field_tag 'event[group_ids][]','' %>

这是因为eventhasmany :groups的关系,所以可以透过属性group_ids直接设定关连。另外,会多一个隐藏的空字串event[group_ids][]是因为当_check box都没有核选时,浏览器不会送出这个属性,我们就无法判断是反核选还是没有选,所以加上一个空值的隐藏字段让Rails可以移除所有关连。

最后记得修改EventsControllerevent_params好可以接收到group_ids参数:

  1. def event_params
  2. params.require(:event).permit(:name, :description, :category_id, :location_attributes => [:id, :name, :_destroy], :group_ids => [] )
  3. end

接着修改show.html.erb显示出Group名称:

  1. <p>Group:
  2. <% @event.groups.each do |g| %>
  3. <%= g.name %>
  4. <% end %>
  5. </p>

客制 Resources (collection)

案例一: 新增不同的页面

我们想要 events 除了 index 页面之外,再新加其他不一样的页面,例如统计资讯、最新推荐、最新活动等等。这时候我们可以新增额外的路由、新的action方法和template样板:

首先修改 routes.rb 在 events 的 resources 区块中加入 collection 区块,collection 表示这一个路由是针对 events 集合来操作:

  1. resources :events do
  2. collection do
  3. get :latest
  4. end
  5. end

注意到在此 routes.rb 上请不要多一行 resources :events,这样根据优先权会优先判断成 events show action。

接着在events_controller.rb新增一个同名的latest action:

  1. def latest
  2. @events = Event.order("id DESC").limit(3)
  3. end

以及它的样板档案/app/views/events/latest.html.erb

案例二:一次删除多笔资料

RESTful中的destroy action是用来删除一笔资料的,如果我们想同时操作多笔资料,就会新增额外的路由和action才处理。例如我们新增这样的路由一次删除所有资料:

  1. resources :events do
  2. collection do
  3. post :bulk_delete
  4. end
  5. end

在样板中加入一个按钮可以执行这个操作:

  1. <%= button_to "Delete All", bulk_delete_events_path, :method => :post %>

events_controller.rb新增bulk_delete方法:

  1. def bulk_delete
  2. Event.destroy_all
  3. redirect_to events_path
  4. end

不过,更常见的作法是用核选方块勾选要操作哪些资料。让我们改一下路由加上bulk_update:

  1. resources :events do
  2. collection do
  3. post :bulk_update
  4. end
  5. end

接着修改app/views/events/index.html.erb帮每个event加上核选方块,并用表单整个包起来:

  1. <%= form_tag bulk_update_events_path do %>
  2. <ul>
  3. <% @events.each do |e| %>
  4. <li>
  5. <%= check_box_tag "ids[]", e.id, false %>
  6. <%= e.name %>
  7. </li>
  8. <% end %>
  9. </ul>
  10. <%= submit_tag "Delete" %>
  11. <%= submit_tag "Publish" %>
  12. <% end %>

新增一个bulk_update方法:

  1. def bulk_update
  2. ids = Array(params[:ids])
  3. events = ids.map{ |i| Event.find_by_id(i) }.compact
  4. if params[:commit] == "Publish"
  5. events.each{ |e| e.update( :status => "published" ) }
  6. elsif params[:commit] == "Delete"
  7. events.each{ |e| e.destroy }
  8. end
  9. redirect_to events_url
  10. end

客制 Resources (member)

案例一:新增 event dashboard 页面

我们想要 event 除了 show 页面之外,还有其他的页面,例如每个活动专属的 dashboard。首先修改 routes.rb 在 events 的 resources 区块中加入 member 区块,member 表示这一个路由是针对特定一个 event 来操作(必须传入某一个 event):

  1. resources :events do
  2. member do
  3. get :dashboard
  4. end
  5. end

这样在events_controller.rb之中就可以多一个action叫做dashboard

  1. def dashboard
  2. @event = Event.find(params[:id])
  3. end

这个样板档案是app/views/events/dashboard.html.erb,我们可以在这一页提供不同于index的内容。

连结的 helper 是 dashboard_event_path(event),例如我们可以在 app/views/events/index.html.erb 的循环中加入:

  1. <%= link_to 'Dashboard', dashboard_event_path(event) %>

回过头来看这种客制member路由,也可以说是一种sub-resource的简化,等同于:

  1. resoruces :events do
  2. resource :dashboard
  3. end

然后这个dashboard controller只有一个Action叫做show

案例二:直接操作 event 资料

虽然透过event update动作我们可以修改event的所有资料,但是有些操作如果有单独的action会比较简单直觉。例如使用者可以点选参加这个活动以及离开这个活动,这时候就可以自订member路由:

  1. resources :events do
  2. member do
  3. post :join
  4. post :withdraw
  5. end
  6. end

接着在event.html.erb中加上两个按钮:

  1. <%= button_to "Join", join_event_path(@event) %>
  2. <%= button_to "Withdraw", withdraw_event_path(@event) %>

以及events_controller.rb新增两个actions,假设我们有一个User model(可以透过使用者认证一章的Devise产生,这样登入后就会有currentuser变量代表登入后的_user)、以及一个eventuser的多对多关系Membership model

  1. def join
  2. @event = Event.find(params[:id])
  3. Membership.find_or_create_by( :event => @event, :user => current_user )
  4. redirect_to :back
  5. end
  6. def withdraw
  7. @event = Event.find(params[:id])
  8. @membership = Membership.find_by( :event => @event, :user => current_user )
  9. @membership.destroy
  10. redirect_to :back
  11. end

另一个路由设计的思路是独立出resources,例如:

resources :events do
  resources :memberships
end

这样样板中的路由Helper会变成:

<%= button_to "Join", event_memberships_path(@event) %>

<% membership = Membership.find_by( :event => @event, :user => current_user ) %>
<%= button_to "Withdraw", event_membership_path(@event, membership) %>

独立出来的memberships_controller.rb内容则是:

def create
  @event = Event.find(params[:event_id])
  Membership.find_or_create_by( :event => @event, :user => current_user )

  redirect_to :back
end

def destroy
  @event = Event.find(params[:event_id])
  @membership = @event.memberships.find( params[:id] )
  @membership.destroy

  redirect_to :back
end

你喜欢哪一种设计呢?

不总是需要新的路由

在上述membercollection的案例中,我们增加了新的action方法和template样板来处理。但如果只是资料不同、样板相同,我们也可以继续沿用本来的action方法和template样板即可。以下示范两种案例:

案例一:让 event index 可以进行关键字搜寻

可以根据搜寻结果来显示所有 events。首先在app/views/events/index.html.erb上方加入一个关键字搜寻的表单:

<%= form_tag events_path, :method => :get do %>
  <%= text_field_tag "keyword" %>
  <%= submit_tag "Search" %>
<% end %>

接着修改index action:

def index
  if params[:keyword]
    @events = Event.where( [ "name like ?", "%#{params[:keyword]}%" ] )
  else
    @events = Event.all
  end

  @events = @events.page(params[:page]).per(5)
end

ActiveRecord的查询是可以串接的,并且直到最后真的要用时才会去查询数据库。这里我们先检查是否有params[:keyword]参数来进行过滤,最后统一进行分页。

SQL 的 like 查询会比对资料表的所有资料,如果资料量很大效能影响很大,请改用全文搜寻引擎。

案例二:让 event index 可以依照参数排序

一样修改 index action:

def index
  sort_by = (params[:order] == 'name') ? 'name' : 'created_at'
  @events = Event.order(sort_by).page(params[:page]).per(5)
end

注意到我们必须先检查 params[:order] 的内容,而不应该直接 order(params[:order])。这会导致有 SQL Injection 安全问题。

在 index.html.erb 中加入排序的超连结:

<%= link_to 'Sort by Name', events_path( :order => "name") %>
<%= link_to 'Sort by Default', events_path %>

同一个index action可能会需要同时兼顾搜寻、排序、分页的功能,这会需要修改index action根据参数来串接这些查询,并且template样板中的超连结也必须包含目前的状态,例如目前是第二页、递降排序等等。

Namespace Resources

案例:新增 event 的管理后台

原有的 events_controller 会作为前台一般使用者之用。为了后台管理用途,我们会另外再新增一个 controller 来操作 Event 这个 model

rails g controller admin::events

这样会产生新的 controller 和 view,放在 admin 目录下。而通常我们会让 admin 管理后台的 layout 不同,以及加上使用者权限验证,例如以下使用最简单的HTTP验证:

class Admin::EventsController < ApplicationController

    before_action :authenticate
    layout "admin"

    # ....

    protected

    def authenticate
       authenticate_or_request_with_http_basic do |user_name, password|
           user_name == "username" && password == "password"
       end
    end

end

那路由要怎么搭配呢?编辑 routes.rb

namespace :admin do
  resources :events
end

这样它的路由 Helper 就会是 admin_events_path 或 admin_event_path(event) 等