手工打造CRUD應用程式
最后更新于:2022-04-01 02:31:11
> Much of the essence of building a program is in fact the debugging of the specification. - Fred Brooks, The Mythical Man-Month 作者
> 請注意本章內容銜接後一章,請與後一章一起完成。
初入門像Rails這樣的功能豐富的開發框架,難處就像雞生蛋、蛋生雞的問題:要了解運作的原理,你必須了解其中的元件,但是如果個別學習其中的元件,又將耗費許多的時間而見樹不見林。因此,為了能夠讓各位讀者能夠儘快建構出一個基本的應用程式,有個大局觀。我們將從一個CRUD程式開始。所謂的CRUD即為Create、Read、Update、Delete等四項基本資料庫操作,本章將示範如何做出這個基本的應用程式,以及幾項Rails常用功能。細節的原理說明則待Part 2後續章節。
## Rails的MVC元件
我們在第一章Ruby on Rails簡介有介紹了什麼是MVC架構,而在Rails中分成幾個不同元件來對應:
* ActiveRecord是Rails的Model元件
* ActionPack包含了ActionDispatch、ActionController和ActionView,分別是Rails的Routing、Controller和View元件
![MVC diagram](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-08-18_55d2cce138c40.png)
這張圖示中的執行步驟是:
1. 瀏覽器發出HTTP request請求給Rails
2. 路由(Routing)根據規則決定派往哪一個Controller的Action
3. 負責處理的Controller Action操作Model資料
4. Model存取資料庫或資料處理
5. Controller Action將得到的資料餵給View樣板
6. 回傳最後的HTML成品給瀏覽器
其中,路由主要是根據HTTP Method方法(GET、POST或是PATCH、DELETE等)以及網址來決定派往到哪一個Controller的Action。例如,我們在「Rails起步走」一章中的`get "welcome/say_hello" => "welcome#say"`意思就是,將GET welcome/say_hello的這個HTTP request請求,派往到welcome controller的say action。
## 認識ActiveRecord操作資料庫
ActiveRecord是Rails的ORM(Object-relational mapping)元件,負責與資料庫溝通,讓你可以使用物件導向語法來操作關聯式資料庫,它的對應概念如下:
* 將資料庫表格(table)對應到一個類別(class)
* 類別方法就是操作這個表格(table),例如新增資料、多筆資料更新或多筆資料刪除
* 資料表中的一筆資料(row)對應到一個物件(object)
* 物件方法就是操作這筆資料,例如更新或刪除這筆資料
* 資料表的欄位(column)就是物件的屬性(object attribute)
所以說資料庫裡面的資料表,我們用一個Model類別表示。而其中的一筆資料,就是一個Model物件。
> 不了解關聯式資料庫的讀者,推薦閱讀[MySQL 超新手入門](http://www.codedata.com.tw/database/mysql-tutorial-getting-started)從第0章至第5章CRUD與資料維護。
> ActiveRecord 這個名稱的由來是它使用了 Martin Fowler 的[Active Record](http://martinfowler.com/eaaCatalog/activeRecord.html)設計模式。
第三章「Rails起步走」我們提到了Scaffold鷹架功能,有經驗的Rails程式設計師雖然不用鷹架產生程式碼,不過還是會使用Rails的generator功能來分別產生Model和Controller檔案。這裡讓我們來產生一個Model:
~~~
$ rails g model event name:string description:text is_public:boolean capacity:integer
~~~
> 這些指令必須要在Rails專案目錄下執行,承第三章也就是demo目錄下。
接著執行以下指令就會建立資料表(如果是使用SQLite3資料庫話,會產生db/development.sqlite3這個檔案):
~~~
$ bin/rake db:migrate
~~~
接著,讓我們使用`rails console`(可以簡寫為`rails c`) 進入主控台模式做練習:
~~~
# 新增
> event = Event.new
> event.name = "Ruby course"
> event.description = "fooobarrr"
> event.capacity = 20
> event.save # 儲存進資料庫,讀者可以觀察另一個指令視窗
> event.id # 輸出主鍵 1,在 Rails 中的主鍵皆為自動遞增的整數 ID
> event = Event.new( :name => "another ruby course", :capacity => 30)
> event.save
> event.id # 輸出主鍵 2,這是第二筆資料
# 查詢
> event = Event.where( :capacity => 20 ).first
> events = Event.where( ["capacity >= ?", 20 ] ).limit(3).order("id desc")
# 更新
> e = Event.find(1) # 找到主鍵為 1 的資料
> e.name # 輸出 Ruby course
> e.update( :name => 'abc', :is_public => false )
# 刪除
> e.destroy
~~~
和irb一樣,要離開rails console請輸入`exit`。如果輸入的程式亂掉沒作用時,直接`Ctrl+Z`離開也沒關係。
> 對資料庫好奇的朋友,可以安裝[DB Browser for SQlite](http://sqlitebrowser.org/)這套工具,實際打開db/development.sqlite3這個檔案觀察看看。
## 認識Migration建立資料表
Rails使用了Migration資料庫遷移機制來定義資料庫結構(Schema),檔案位於db/migrate/目錄下。它的目的在於:
* 讓資料庫的修改也可以納入版本控制系統,所有的變更都透過撰寫Migration檔案執行
* 方便應用程式更新升級,例如讓軟體從第三版更新到第五版,資料庫更新只需要執行`rake db:migrate`
* 跨資料庫通用,不需修改程式就可以用在SQLite3、MySQL、Postgres等不同資料庫
在上一節產生Model程式時,Rails就會自動幫你產生對應的Migration檔案,也就是如db/migrate/20110519123430_create_events.rb的檔案。Rails會用時間戳章來命名檔案,所以每次產生檔名都不同,這樣可以避免多人開發時的衝突。其內容如下:
~~~
# db/migrate/20110519123430_create_events.rb
class CreateEvents < ActiveRecord::Migration
def change
create_table :events do |t|
t.string :name
t.text :description
t.boolean :is_public
t.integer :capacity
t.timestamps
end
end
end
~~~
其中的create_table區塊就是定義資料表結構的程式。上一節中我們已經執行過`bin/rake db:migrate`來建立此資料表。
Migration檔案不需要和Model一一對應,像我們來新增一個Migration檔案來新增一個資料庫欄位,請執行:
~~~
$ rails g migration add_status_to_events
~~~
如此就會產生一個空的 migration 檔案在 db/migrate 目錄下。Migration 有提供 API 讓我們可以變更資料庫結構。例如,我們可以新增一個欄位。輸入`rails g migration add_status_to_events`然後編輯這個Migration檔案:
~~~
# db/migrate/20110519123819_add_status_to_events.rb
class AddStatusToEvents < ActiveRecord::Migration
def change
add_column :events, :status, :string
end
end
~~~
接著執行`bin/rake db:migrate`就會在events表格中新增一個status的欄位,欄位型別是string。Rails會記錄你已經對資料庫操作過哪些Migrations,像此例中就只會跑這個Migration而已,就算你多執行幾次`bin/rake db:migrate`也只會對資料庫操作一次。
> Rails透過資料庫中的schema_migrations這張table來記錄已經跑過哪些Migrations。
## 認識ActiveRecord資料驗證(Validation)
ActiveRecord的資料驗證(Validation)功能,可以幫助我們檢查資料的正確性。如果驗證失敗,就會無法存進資料庫。
編輯app/models/event.rb加入
~~~
class Event < ActiveRecord::Base
validates_presence_of :name
end
~~~
其中的validates_presence_of宣告了name這個屬性是必填。我們按Ctrl+Z離開主控台重新進入,或是輸入 reload!,這樣才會重新載入。
~~~
> e = Event.new
> e.save # 回傳 false
> e.errors.full_messages # 列出驗證失敗的原因
> e.name = 'ihower'
> e.save
> e.errors.full_messages # 儲存成功,變回空陣列 []
~~~
呼叫save時,ActiveRecord就會驗證資料的正確性。而這裡因為沒有填入name,所以回傳false表示儲存失敗。
## 實做基本的CRUD應用程式
有了Event model,將下來讓我們實作出完整的CRUD使用者介面流程吧,這包含了列表頁面、新增頁面、編輯頁面以及個別資料頁面。
### 外卡路由
我們在「Rails起步走」一章分別為welcome/say_hello和welcome設定路由,也就如何將網址對應到Controller和Action。但是如果每個路徑都需要一條條設定會太麻煩了。這一章我們使用一種外卡路由的設定,編輯config/routes.rb在最後插入一行:
~~~
# ....
match ':controller(/:action(/:id(.:format)))', :via => :all
end
~~~
外卡路由很容易理解,它會將/foo/bar這樣的網址自動對應到Controller foo的bar Action、如果是/foo這樣的網址,則預設對應到index action。我們再下一章中我們會再改用另一種稱作RESTful路由方式。
### 列出所有資料
執行rails g controller events,首先編輯app/controllers/events_controller.rb加入
~~~
def index
@events = Event.all
end
~~~
`Event.all`會抓出所有的資料,回傳一個陣列給實例變數(instance variables)指派給`@events`。在Rails會讓Action裡的實例變數(也就是有`@`開頭的變數)通通傳到View樣板裡面可以使用。這個Action預設使用的樣板是app/views/events/目錄下與Action同名的檔案,也就是接下來要編輯的app/views/events/index.html.erb,內容如下:
~~~
<ul>
<% @events.each do |event| %>
<li>
<%= event.name %>
<%= link_to 'Show', :controller => 'events', :action => 'show', :id => event %>
<%= link_to 'Edit', :controller => 'events', :action => 'edit', :id => event %>
<%= link_to 'Delete', :controller => 'events', :action => 'destroy', :id => event %>
</li>
<% end %>
</ul>
<%= link_to 'New Event', :controller => 'events', :action => 'new' %>
~~~
這個View迭代了`@events`陣列並顯示內容跟超連結,有幾件值得注意的事情:
`<%`和`<%=`不太一樣,前者只執行不輸出,像用來迭代的`each`和`end`這兩行就不需要輸出。而後者`<%=` 裡的結果會輸出給瀏覽器。
`link_to`建立超連結到一個特定的位置,這裡為瀏覽、編輯和刪除都提供了超連結。
連往http://localhost:3000/events就會看到這一頁。目前還沒有任何資料,讓我們繼續實作點擊New Event超連結之後的動作。
### 新增資料
建立一篇新的活動需要兩個Actions。第一個是new Action,它用來實例化一個空的`Event`物件,編輯app/controllers/events_controller.rb加入
~~~
def new
@event = Event.new
end
~~~
這個app/views/events/new.html.erb會顯示空的`Event`給使用者:
~~~
<%= form_for @event, :url => { :controller => 'events', :action => 'create' } do |f| %>
<%= f.label :name, "Name" %>
<%= f.text_field :name %>
<%= f.label :description, "Description" %>
<%= f.text_area :description %>
<%= f.submit "Create" %>
<% end %>
~~~
這個`form_for`的程式碼區塊(Code block)被用來建立HTML表單。在區塊中,你可以使用各種函式來建構表單。例如`f.text_field :name`建立出一個文字輸入框,並填入`@event`的name屬性資料。但這個表單只能基於這個Model有的屬性(在這個例子是name跟description)。Rails偏好使用`form_for`而不是讓你手寫表單HTML,這是因為程式碼可以更加簡潔,而且可以明確地連結在Model物件上。
`form_for`區塊也很聰明,New Event的表單跟Edit Event的表單,其中的送出網址跟按鈕文字會不同的(根據`@event`的不同,前者是新建的,後者是已經建立過的)。
> 如果你需要建立任意欄位的HTML表單,而不綁在某一個Model上,你可以使用`form_tag`函式。它也提供了建構表單的函式而不需要綁在Model實例上。我們會在Action View: Helpers一章介紹。
當一個使用者點擊表單的Create按鈕時,瀏覽器就會送出資料到Controller的create Action。也是一樣編輯app/controllers/events_controller.rb加入:
~~~
def create
@event = Event.new(params[:event])
@event.save
redirect_to :action => :index
end
~~~
create Action會透過從表單傳進來的資料,也就是Rails提供的`params`參數(這是一個Hash),來實例化一個新的`@event`物件。成功儲存之後,便將使用者重導(redirect)至index Action顯示活動列表。
讓我們來實際測試看看,在瀏覽器中實際按下表單的Create按鈕後,出現了`ActiveModel::ForbiddenAttributesError in EventsController#create`的錯誤訊息,這是因為Rails會檢查使用者傳進來的參數必須經過一個過濾的安全步驟,這個機制叫做Strong Parameters,讓我們回頭修改app/controllers/events_controller.rb
~~~
def create
@event = Event.new(event_params)
@event.save
redirect_to :action => :index
end
private
def event_params
params.require(:event).permit(:name, :description)
end
~~~
我們新加了一個`event_params`方法,其中透過`require`和`permit`將`params`這個Hash過濾出`params[:event][:name]`和`params[:event][:description]`。
> `private`以下的所有方法都會變成private方法,所以記得放在檔案的最底下。
再次測試看看,應該就可以順利新增資料了。
### 顯示個別資料
當你在index頁面點擊show的活動連結,就會前往http://localhost:3000/events/show/1這個網址。Rails會呼叫show action並設定`params[:id]`為`1`。以下是show Action:
編輯app/controllers/events_controller.rb加入
~~~
def show
@event = Event.find(params[:id])
end
~~~
這個show Action用`find`方法從資料庫中找出該篇活動。找到資料之後,Rails用show.html.erb樣板顯示出來。新增app/views/events/show.html.erb,內容如下:
~~~
<%= @event.name %>
<%= simple_format(@event.description) %>
<p><%= link_to 'Back to index', :controller => 'events', :action => 'index' %></p>
~~~
其中`simple_format`是一個內建的View Helper,它的作用是可以將換行字元`\n`置換成`<br />`,有基本的HTML換行效果。
### 編輯資料
如同建立新活動,編輯活動也有兩個步驟。第一個是請求特定一篇活動的edit頁面。這會呼叫Controller的edit Action,編輯app/controllers/events_controller.rb加入
~~~
def edit
@event = Event.find(params[:id])
end
~~~
找到要編輯的活動之後,Rails接著顯示edit.html.erb頁面,新增app/views/events/edit.html.erb檔案,內容如下:
~~~
<%= form_for @event, :url => { :controller => 'events', :action => 'update', :id => @event } do |f| %>
<%= f.label :name, "Name" %>
<%= f.text_field :name %>
<%= f.label :description, "Description" %>
<%= f.text_area :description %>
<%= f.submit "Update" %>
<% end %>
~~~
這裡跟new Action很像,只是送出表單後,是前往Controller的update Action:
~~~
def update
@event = Event.find(params[:id])
@event.update(event_params)
redirect_to :action => :show, :id => @event
end
~~~
在update Action裡,Rails一樣透過`params[:id]`參數找到要編輯的資料。接著`update`方法會根據表單傳進來的參數修改到資料上,這裡我們沿用`event_params`這個方法過濾使用者傳進來的資料。如果一切正常,使用者會被導向到活動的show頁面。
### 刪除資料
最後,點擊Destroy超連結會前往destroy Action,編輯app/controllers/events_controller.rb加入
~~~
def destroy
@event = Event.find(params[:id])
@event.destroy
redirect_to :action => :index
end
~~~
`destroy`方法會刪除對應的資料庫資料。完成之後,將使用者導向index頁面。
> Rails的程式風格非常注重變數命名的單數複數,像上述的index Action中是用`@events`複數命名,代表這是一個群集陣列。其他則是用`@event`單數命名。
## 認識版型(Layout)
Layout可以用來包裹樣板,讓不同樣板共用相同的HTML開頭和結尾部分。當Rails要顯示一個樣板給瀏覽器時,它會將樣板的HTML放到Layout的HTML之中。預設的Layout檔案是app/views/layouts/application.html.erb,其中`yield`就是會被替換成樣板的地方。所有的樣版預設都會套這個Layout。我們會再 Action View一章中介紹如何更換不同Layout。
現在,讓我們修改Layout中的`<title>`:
~~~
<!DOCTYPE html>
<html>
<head>
<title><%= @page_title || "Event application" %></title>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
<%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
<%= csrf_meta_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
~~~
如此我們可以在show Action中設定`@page_title`的值:
~~~
def show
@event = Event.find(params[:id])
@page_title = @event.name
end
~~~
這樣的話,進去show頁面的title就會是活動名稱。其他頁面因為沒有設定`@page_title`,就會是”Event application”。
## 認識局部樣板(Partial Template)
利用局部樣板(Partial)機制,我們可以將重複的樣板獨立出一個單獨的檔案,來讓其他樣板共享引用。例如new.html.erb和edit.html.erb都有以下相同的樣板程式:
~~~
<%= f.label :name, "Name" %>
<%= f.text_field :name %>
<%= f.label :description, "Description" %>
<%= f.text_area :description %>
~~~
一般來說,新增和編輯時的表單欄位都是相同的,所以讓我們將這段樣板程式獨立出一個局部樣板,這樣要修改欄位的時候,只要修改一個檔案即可。局部樣板的命名都是底線`_`開頭,新增一個檔案叫做`_form.html.erb`,內容就如上。如此new.html.erb就可以變成:
~~~
<%= form_for @event, :url => { :controller => 'events', :action => 'create' } do |f| %>
<%= render :partial => 'form', :locals => { :f => f } %>
<%= f.submit "Create" %>
<% end %>
~~~
而edit.html.erb則是:
~~~
<%= form_for @event, :url => { :controller => 'events', :action => 'update', :id => @event } do |f| %>
<%= render :partial => 'form', :locals => { :f => f } %>
<%= f.submit "Update" %>
<% end %>
~~~
透過`<%= render :partial => 'form', :locals => { :f => f } %>`會引用`_form.html.erb`這個局部樣板,並將變數`f`傳遞進去變成區域變數。
## `before_action`方法
透過`before_action`,我們可以將Controller中重複的程式獨立出來。
在events_controller.rb上方新增
~~~
before_action :set_event, :only => [ :show, :edit, :update, :destroy]
~~~
在下方`private`後面新增一個方法如下:
~~~
def set_event
@event = Event.find(params[:id])
end
~~~
> Controller中的公開(public)方法都是Action,也就是可以讓瀏覽器呼叫使用的動作。使用`protected`或`private`可以避免內部方法被當做Action使用。
刪除show、edit、update、destroy方法中的
~~~
@event = Event.find(params[:id])
~~~
## 加入資料驗證
我們在資料驗證一節中,已經加入了name的必填驗證,因此當使用者送出沒有name的表單,就會無法儲存進資料庫。我們希望目前的程式能夠在驗證失敗後,提示使用者儲存失敗,並讓使用者有機會可以修改再送出。
修改app/controllers/events_controller.rb的create和update Action
~~~
def create
@event = Event.new(event_params)
if @event.save
redirect_to :action => :index
else
render :action => :new
end
end
~~~
如果活動因為驗證錯誤而儲存失敗,這裡會回傳給使用者帶有錯誤訊息的new Action,好讓使用者可以修正問題再試一次。實際上,`render :action => "new"`會回傳new Action所使用的樣板,而不是執行new action這個方法。如果改成使用`redirect_to`會讓瀏覽器重新導向到new Action,但是如此一來`@event`就被重新建立而失去使用者剛輸入的資料。
~~~
def update
if @event.update(event_params)
redirect_to :action => :show, :id => @event
else
render :action => :edit
end
end
~~~
更新時也是一樣,如果驗證有任何問題,它會顯示edit頁面好讓使用者可以修正資料。
而為了可以在儲存失敗時顯示錯誤訊息,接著編輯`_form.html.erb`中加入
~~~
<% if @event.errors.any? %>
<ul>
<% @event.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
<% end %>
~~~
## 認識Flash訊息
請在app/views/layouts/application.html.erb Layout檔案之中,`yield`之前加入:
~~~
<p style="color: green"><%= flash[:notice] %></p>
<p style="color: red"><%= flash[:alert] %></p>
~~~
接著讓我們回到app/controllers/events_controller.rb,在create Action中加入
~~~
flash[:notice] = "event was successfully created"
~~~
在update Action中加入
~~~
flash[:notice] = "event was successfully updated"
~~~
在destroy Action中加入
~~~
flash[:alert] = "event was successfully deleted"
~~~
「event was successfully created」訊息會被儲存在Rails的特殊flash變數中,好讓訊息可以被帶到另一個 action,它提供使用者一些有用的資訊。在這個create Action中,使用者並沒有真的看到任何頁面,因為它馬上就被導向到新的活動頁面。而這個flash變數就帶著訊息到下一個Action,好讓使用者可以在show Action頁面看到 「event was successfully created.」這個訊息。
## 分頁外掛
上述的程式用`Event.all`一次抓出所有活動,這在資料量一大的時候非常浪費效能和記憶體。通常會用分頁機制來限制抓取資料的筆數。
編輯Gemfile加入以下程式,這個檔案設定了此應用程式使用哪些套件。這裡我們使用[kaminari](https://github.com/amatsuda/kaminari)這個分頁套件:
~~~
gem "kaminari"
~~~
執行`bundle install`就會安裝。裝好後需要重新啟動伺服器才會載入。
修改app/controllers/events_controller.rb的index Action如下
~~~
def index
@events = Event.page(params[:page]).per(5)
end
~~~
編輯app/views/events/index.html.erb,加入
~~~
<%= paginate @events %>
~~~
連往http://localhost:3000/events/,你可能需要多加幾筆資料就會看到分頁連結了。