← 上一章:Model 基本操作 下一章:Model 關連性 →

Model Migration

資料遷移(Migration)是很多剛接觸 Rails 的新手容易卡關的地方,對 Migration 常見的誤解有:

  1. Migration 就是資料庫。
  2. 只要在 Migration 修改欄位後,網頁上自動就會呈現修改後的效果。
  3. 如果 Migration 寫錯,只要修改後再重新執行 rails db:migrate 指令就行了。

什麼是 Migration

Migration 是用來描述「資料庫的架構長什麼樣子」的檔案,它會隨著專案開發的過程中逐漸增加。讓我們先想像一下這個對話內容:

同事 A:「嘿,我剛剛建立了一個 User 資料表喔」

同事 B:「好,那我待會要建一個 Product 資料表用來放產品資訊的」

同事 C:「咦?等等,這個 User 資料表少一個地址欄位啦,我要加上去喔」

同事 A:「Product 資料表的 name 欄位不太好記,我要把它改名成 title 喔」

同事 C:「User 資料表的這個 flag 欄位好像都沒用到,我要把這個欄位刪除喔」

這段對話的過程就是所謂的 Migration,上面這樣其實就算是發生了 5 次的 Migration。在 Rails 專案中,每個 Migration 都是一個描述檔案。透過 Git 共同開發,每位同事應該都能拿到一樣的 Migration 描述檔,只要一個指令就可以同步資料庫的結構,比較不會有「同事 A 直接在 Server 上修改某個欄位的名字,但同事 B 不知情而造成程式無法順利執行」的情況發生。

使用 Migration 的另一個好處,是因為 Migration 檔案應該都會進 Git 版本控制,所以整個資料庫的設計過程全部都可以一目了然。而且假設原本使用 MySQL 資料庫,突然被公司長官要求要換成 PostgreSQL,只要沒有用到太特別或某些資料庫專屬的特異功能,通常只要一行指令就可以再重建資料庫。

新增 Migration

要新增 Migration 有好幾種管道,例如透過 rails generate 指令產生 Model 或 Scaffold 都會順便產生一個 Migration 檔。讓我們先用 generate 產生一個 Model 吧:

$ bin/rails g model Article title content:text is_online:boolean
Running via Spring preloader in process 1480
      invoke  active_record
      create    db/migrate/20161231224701_create_articles.rb
      create    app/models/article.rb
      invoke    test_unit
      create      test/models/article_test.rb
      create      test/fixtures/articles.yml

除了 Model 本體外,這個指令也產生了一個名為 20161231224701_create_articles.rb 的 Migration 檔案,其中檔名前面的 20161231224701 是這個指令執行時候的時間戳記。讓我們看一下這個 Migration 檔案的內容:

class CreateArticles < ActiveRecord::Migration[5.0]
  def change
    create_table :articles do |t|
      t.string :title
      t.text :content
      t.boolean :is_online

      t.timestamps
    end
  end
end

Migration 檔的內容本質上就是一個 Ruby 程式,從語法大概能猜得出來要建立一個名為 articles 的資料表,並且有 titlecontent 以及 is_online 這幾個欄位。

除了這幾個欄位外,在最後一行還有一個 t.timestamps 的語法,會幫你在這個表格分別建立出 created_at 以及 updated_at 兩個時間戳記欄位,會在資料新增及更新的時候把當下的時間寫進去。如果覺得這個資料表不需要這樣的時間欄位,亦可直接把這行刪除。

有了 Migration,記得要執行 rails db:migrate 指令,這樣就會把這些描述轉換成真實的資料表:

$ bin/rails db:migrate
== 20161231224701 CreateArticles: migrating ===================================
-- create_table(:articles)
   -> 0.0050s
== 20161231224701 CreateArticles: migrated (0.0051s) ==========================

如果你忘了執行 bin/rails db:migrate 指令…

在 Rails 專案中如果有 Migration 檔案還沒有「處理」過,在你開瀏覽器檢視頁面的時候會看到「ActiveRecord::PendingMigrationError」的錯誤訊息:

image

不用太擔心,就如同錯誤畫面所說的,只要執行一下 bin/rails db:migrate 指令就可以解決問題了 :)

想要在其它環境下執行 Migrate

預設的 bin/rails db:migrate 會在開發模式(development)執行,如果你想在 production 或 test 環境執行,只要在執行指令的時候,改一下環境變數就行,像這樣:

$ bin/rails db:migrate RAILS_ENV=production

這樣就會以 production 模式來執行 Migration 了。

修改 Migration

剛執行完一個 Migration,才發現欄位名字打錯了,想要修改該怎麼做?直覺做法是「修改剛剛那個 Migration 檔案,存檔後再執行一次 bin/rails db:migrate 吧」

但這方法行不通的,因為 bin/rails db:migrate 這個指令只會針對還沒執行過的 Migration 檔案有效果,已經做過的 Migration 再做一次是不會有反應的,所以即使修改同一個 Migration 檔再重新執行是沒用的。

那怎麼辦?其實做法有好幾款,其中一款,就是執行 Rollback 指令,把執行過的 Migration 倒回去:

$ bin/rails db:rollback
== 20161231224701 CreateArticles: reverting ===================================
-- drop_table(:articles)
   -> 0.0024s
== 20161231224701 CreateArticles: reverted (0.0079s) ==========================

這樣就可以「倒轉」一個 Migration。如果一次想要倒轉 3 個 Migration,可以加上 STEP=3 參數:

$ bin/rails db:rollback STEP=3

雖然我們在 Migration 裡只有寫 create_table 語法,但上面這個指令會自動幫我們執行 drop_table 來刪除新增的資料表。

在執行 Rollback 的時候,如果正向 Migration 是建立資料表,那反向的 Migration 就是刪除資料表;同理,如果正向是新增欄位,反向就是刪除欄位。

注意:Rollback 是有風險的!

因為 Rollback 通常會造成刪除資料表或是刪除欄位的效果,所以如果原本該資料表或該欄位已經有資料,請盡量不要使用 Rollback 方式來修正 Migration,建議直接再新增一個 Migration 來進行修正。

Rails 怎麼知道哪些 Migration 有做過?

其實在資料庫裡有一個名為 schema_migrations 的資料表,裡面有記錄哪些 Migration 已經做過的。除了可以直接進這個資料表看之外,也可使用這個指令查看:

$ bin/rails db:migrate:status
database: /private/tmp/my_candidates/db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20161229084544  Create candidates
  down    20161231224701  Create articles
  down    20170101064253  Create comments

其中狀態是 up 的表示這個 Migration 已執行過,down 則是尚未執行。

手工產生 Migration

當想要修正 Migration 的時候,前面提到 Rollback 後修改再重做一次 Migration 的做法其實是不太推薦的,因為這樣做除了可能會刪除原有的資料之外,如果這個專案還有跟其它人協同開發,你也得要求其它同事 Rollback 重做一次,這實在會造成別人的困擾。

除非這個案子剛開始,或是只有你自己一個人在做,否則要進行資料庫結構修改的事,建議另外新增一個 Migration 來修正。例如我想要幫 articles 資料表新增一個名為 photo 的字串欄位:

$ bin/rails g migration add_photo_to_articles
Running via Spring preloader in process 7437
  invoke  active_record
  create    db/migrate/20170101081107_add_photo_to_articles.rb

這邊的 add_photo_to_articles 並不一定要這樣寫,你要使用 abcxyz 都沒問題,但建議使用一眼就看得出意圖的寫法跟用字,日後在維護的時候比較容易光看檔名就知道到底這次 Migration 做了什麼事。讓我們看看剛剛產生的那個的 Migration 檔:

class AddPhotoToArticles < ActiveRecord::Migration[5.0]
  def change
  end
end

其實產生器也只幫你產生了一個空殼而已,沒有真正的實作,所以接下來就要在裡面寫上我想要加的欄位:

class AddPhotoToArticles < ActiveRecord::Migration[5.0]
  def change
    add_column :articles, :photo, :string
  end
end

add_column 這個方法,第一個參數是「資料表名稱」(注意:不是 Model 名稱喔),第二個參數是「要新增的欄位名稱」,第三個參數是這個欄位的「資料型態」。完成並存檔之後,則可繼續執行 Migration:

$ bin/rails db:migrate
== 20170101081107 AddPhotoToArticles: migrating ===============================
-- add_column(:articles, :photo, :string)
   -> 0.0004s
== 20170101081107 AddPhotoToArticles: migrated (0.0004s) ======================

這樣一來,其它同事透過 Git 收到這個 Migration 檔的時候,同樣只要執行 rails db:migrate 指令,就可以跟你有一樣的資料表結構了。

魔術 Migration 產生器

Migration 的產生其實還滿神奇的,當你的檔名符合某些字樣的時候,例如 add ... to ... 或是 remove ... from ...,後面再加一些欄位,可以自動幫你產生一個寫好的 Migration 檔案,例如這樣:

$ bin/rails g migration add_candidate_id_to_articles candidate_id:integer:index
Running via Spring preloader in process 7765
  invoke  active_record
  create    db/migrate/20170101081538_add_candidate_id_to_articles.rb

我要 add 一個欄位 to articles 這個表格,同時幫這個欄位加上索引。看一下它幫我們產生的 Migration 檔:

class AddCandidateIdToArticles < ActiveRecord::Migration[5.0]
  def change
    add_column :articles, :candidate_id, :integer
    add_index :articles, :candidate_id
  end
end

突然就很魔術的寫好了!

雖然說這樣挺方便,但我沒辦法記得太多這樣的魔術寫法,我個人比較偏好開一個 Migration 再慢慢自己寫,反正也不會慢到哪裡去。如果你對這樣的魔術寫法有興趣,請查閱 Rails Guide 的 Migration 章節

schema.rb 是什麼東西?

在執行 rails db:migrate 指令之後,在專案的 db 目錄裡有個名為 schema.rb 的檔案,內容可能長得像這樣:

ActiveRecord::Schema.define(version: 20170101081538) do
  # ...[略]...
  create_table "candidates", force: :cascade do |t|
    t.string   "name"
    t.string   "party"
    t.integer  "age"
    t.text     "politics"
    t.integer  "votes",      default: 0
    t.datetime "created_at",             null: false
    t.datetime "updated_at",             null: false
  end
  # ...[略]...
end

這個檔案是你在執行 rails db:migrate 指令的時候順便一起產生的,你不需要也沒必要手動修改這個檔案。從這個檔案可以看得出來每個資料表的名字與欄位名稱、型態。這個檔案通常會在版本控制系統裡,如果有些比較老舊的專案,中間有些 Migration 檔因為不明原因壞掉了而無法順利執行 rails db:migrate,這時候也可透過 rails db:schema:load 把資料表建回來。

另外,因為這個檔案的內容是由 rails db:migrate 指令產生,所以偶爾會遇到新手「Migration 寫好但還沒存檔就執行」的狀況,這時候從這個 schema.rb 檔案就可以看得出來。

「Migration 寫好但還沒存檔就執行」會怎樣?

其實不會怎樣,就只是執行了一個空的 Migration 而已。

但因為執行過的 Migration 檔不會再重複執行,所以有些對 Migration 還不熟的新手,以為已經正確的執行了 Migration 檔,但事實上根本就是執行了一個空的 Migration,這時候即使再存檔也沒效果了。

所以常會發生「奇怪,怎麼明明 Migration 檔案裡就有寫這些欄位,但為什麼 schema.rb 檔案裡卻沒有」的情況。

種子資料

前面對 Migration 的介紹,好像都是在建立或修改資料庫的結構,事實上如果要的話,也可以在 Migration 的過程順便寫資料進去的,例如這樣:

class CreateArticles < ActiveRecord::Migration[5.0]
  def change
    create_table :articles do |t|
      t.string :title
      t.text :content
      t.boolean :is_online

      t.timestamps
    end

    Article.create(title: "五倍紅寶石 part 1", content: "斷開鎖鍊吧!")
    Article.create(title: "五倍紅寶石 part 2", content: "斷開魂結吧!")
  end
end

這樣的技巧常用在建立資料表的時候順便建立初始資料,例如預設的系統管理帳號。以上面這段範例來說,當執行 rails db:migrate 的同時也會順便一併新增兩筆資料到 articles 資料表。

雖然這樣可以寫入預設資料沒錯,但 Migration 的特性之一,就是已經處理過的 Migration 不會再執行(除非 Rollback 回去),所以如果要重新再重建這些預設資料會有點麻煩。在 Rails 裡有個更適合做這件事的地方,就是在 db/seeds.rb 這個檔案,請直接編輯這個檔案的內容:

Article.create(title: "五倍紅寶石 part 1", content: "斷開鎖鍊吧!")
Article.create(title: "五倍紅寶石 part 2", content: "斷開魂結吧!")

存檔後,執行 bin/rails db:seed 指令,就可以把資料寫進資料庫裡了,不管是目的性或是實用性來說,把預設資料放在這裡都是比較好的做法。

另外,bin/rails db:setup 指令其實除了建立資料庫之外,也隱含了執行 bin/rails db:seed 的指令,所以如果是全新的資料庫,執行 bin/rails db:setup 可一口氣把資料表建好,順便把預設資料寫入。

← 上一章:Model 基本操作 下一章:Model 關連性 →

Comments