Clone repository
$ git clone [email protected]:whyayen/hahow-homework.git
請確保環境有 PostgreSQL,並至 config/database.yml
修改 DB 連線設定
Install dependencies
$ bundle install
Create database
$ bin/rails db:create
Run migrations
$ bin/rails db:migrate
Start Server
$ bin/rails s
https://murmuring-dawn-21241.herokuapp.com/
chapters.course_id
是外鍵 ON DELETE 是CASCADE
模式units.chapter_id
是外鍵 ON DELETE 是CASCADE
模式
API 架構
- 獲取課程列表
GET /api/courses
- 獲取課程內容
GET /api/courses/:id
- 建立課程
POST /api/courses
- 更新課程內容
PUT /api/courses/:id
- 刪除課程
DELETE /api/courses/:id
API 的 response 及 request 都會首字小寫駝峰式命名,詳細 payload 請參考下列各 API。會用駝峰式命名主要是考慮大部分前端都以駝峰式命名為主,如此在 API 溝通時會較為一致。
GET /api/courses
Example Response:
[
{
"id": 1,
"name": "測試課程",
"teacher": "李麗娜",
"description": null,
"chapters": [
{
"id": 1,
"name": "第 1 章",
"sequencePosition": 0,
"units": [
{
"id": 1,
"name": "第 1 單元",
"description": "聽我說",
"content": "blah blah",
"sequencePosition": 0
},
{
"id": 2,
"name": "第 2 單元",
"description": "聽我說",
"content": "blah blah",
"sequencePosition": 1
},
{
"id": 3,
"name": "第 3 單元",
"description": "聽我說",
"content": "blah blah",
"sequencePosition": 2
}
]
},
{
"id": 2,
"name": "第 2 章",
"sequencePosition": 1,
"units": [
{
"id": 4,
"name": "第 4 單元",
"description": "wat",
"content": "Second",
"sequencePosition": 0
},
{
"id": 5,
"name": "第 5 單元",
"description": "wat",
"content": "Secondh",
"sequencePosition": 1
}
]
}
]
}
]
Response 欄位說明:
id
: 課程 IDname
: 課程名稱teacher
: 講師名稱description
: 課程說明chapters
: 課程章節id
: 章節 IDname
: 章節名稱sequencePosition
: 章節順序,越小越前面units
: 章節包含的單元id
: 單元 IDname
: 單元名稱description
: 單元說明content
: 單元內容sequencePosition
: 單元順序,越小越前面
GET /api/courses/:id
Example Response:
{
"id": 1,
"name": "測試課程",
"teacher": "李麗娜",
"description": null,
"chapters": [
{
"id": 1,
"name": "第 1 章",
"sequencePosition": 0,
"units": [
{
"id": 1,
"name": "第 1 單元",
"description": "聽我說",
"content": "blah blah",
"sequencePosition": 0
},
{
"id": 2,
"name": "第 2 單元",
"description": "聽我說",
"content": "blah blah",
"sequencePosition": 1
},
{
"id": 3,
"name": "第 3 單元",
"description": "聽我說",
"content": "blah blah",
"sequencePosition": 2
}
]
},
{
"id": 2,
"name": "第 2 章",
"sequencePosition": 1,
"units": [
{
"id": 4,
"name": "第 4 單元",
"description": "wat",
"content": "Second",
"sequencePosition": 0
},
{
"id": 5,
"name": "第 5 單元",
"description": "wat",
"content": "Secondh",
"sequencePosition": 1
}
]
}
]
}
Response 欄位說明:
id
: 課程 IDname
: 課程名稱teacher
: 講師名稱description
: 課程說明chapters
: 課程章節id
: 章節 IDname
: 章節名稱sequencePosition
: 章節順序,越小越前面units
: 章節包含的單元id
: 單元 IDname
: 單元名稱description
: 單元說明content
: 單元內容sequencePosition
: 單元順序,越小越前面
POST /api/courses
Example Request:
{
"name": "Ruby on Rails 從入門到放棄",
"teacher": "老王",
"description": "輕鬆放棄 Ruby on Rails",
"chapters": [
{
"name": "第 1 章 - 介紹",
"sequencePosition": 0,
"units": [
{
"name": "第 1 單元 - Ruby 歷史",
"description": "Ruby 的成長史",
"content": "在20世紀90年代中期由日本電腦科學家松本行弘(Matz)設計並開發。",
"sequencePosition": 0
},
{
"name": "第 2 單元 - Hello World!",
"description": "第一行 Ruby code",
"content": "puts 'Hello World!'",
"sequencePosition": 1
},
{
"name": "第 3 單元 - Rails 歷史",
"description": "Rails 的成長史",
"content": "Ruby on Rails(官方簡稱為Rails,亦被簡稱為RoR),是一個使用Ruby語言寫的開源Web應用框架,它是嚴格按照MVC結構開發,努力使自身保持簡單,使實際應用開發時的代碼更少,使用最少的組態。",
"sequencePosition": 2
}
]
},
{
"name": "第 2 章",
"sequencePosition": 1,
"units": [
{
"name": "第 4 單元",
"description": "學會放棄",
"content": "PHP is the best.",
"sequencePosition": 0
},
{
"name": "第 5 單元",
"description": "不碰 Rails",
"content": "Laravel is awesome.",
"sequencePosition": 1
}
]
}
]
}
Request 欄位說明:
name
: 課程名稱(必填)teacher
: 講師名稱(必填)description
: 課程說明chapters
: 課程章節name
: 章節名稱(必填)sequencePosition
: 章節順序,越小越前面units
: 章節包含的單元name
: 單元名稱(必填)description
: 單元說明content
: 單元內容(必填)sequencePosition
: 單元順序,越小越前面
注意這裡 chapters 跟 units 的順序不是照 Array 的順序,而是照 sequencePosition
的值!
Example Response:
{
"id": 2,
"name": "Ruby on Rails 從入門到放棄",
"teacher": "老王",
"description": "輕鬆放棄 Ruby on Rails",
"chapters": [
{
"id": 4,
"name": "第 1 章 - 介紹",
"sequencePosition": 0,
"units": [
{
"id": 10,
"name": "第 1 單元 - Ruby 歷史",
"description": "Ruby 的成長史",
"content": "在20世紀90年代中期由日本電腦科學家松本行弘(Matz)設計並開發。",
"sequencePosition": 0
},
{
"id": 11,
"name": "第 2 單元 - Hello World!",
"description": "第一行 Ruby code",
"content": "puts 'Hello World!'",
"sequencePosition": 1
},
{
"id": 12,
"name": "第 3 單元 - Rails 歷史",
"description": "Rails 的成長史",
"content": "Ruby on Rails(官方簡稱為Rails,亦被簡稱為RoR),是一個使用Ruby語言寫的開源Web應用框架,它是嚴格按照MVC結構開發,努力使自身保持簡單,使實際應用開發時的代碼更少,使用最少的組態。",
"sequencePosition": 2
}
]
},
{
"id": 5,
"name": "第 2 章",
"sequencePosition": 1,
"units": [
{
"id": 13,
"name": "第 4 單元",
"description": "學會放棄",
"content": "PHP is the best.",
"sequencePosition": 0
},
{
"id": 14,
"name": "第 5 單元",
"description": "不碰 Rails",
"content": "Laravel is awesome.",
"sequencePosition": 1
}
]
}
]
}
Response 欄位說明:
id
: 課程 IDname
: 課程名稱teacher
: 講師名稱description
: 課程說明chapters
: 課程章節id
: 章節 IDname
: 章節名稱sequencePosition
: 章節順序,越小越前面units
: 章節包含的單元id
: 單元 IDname
: 單元名稱description
: 單元說明content
: 單元內容sequencePosition
: 單元順序,越小越前面
PUT /api/courses/:id
Example Request:
{
"id": 2,
"name": "Ruby on Rails 從入門到放棄",
"teacher": "張三",
"description": "輕鬆放棄 Ruby on Rails",
"chapters": [
{
"id": 4,
"name": "第 1 章 - 介紹",
"sequencePosition": 0,
"units": [
{
"id": 10,
"name": "第 1 單元 - Ruby 歷史",
"description": "Ruby 的成長史",
"content": "在20世紀90年代中期由日本電腦科學家松本行弘(Matz)設計並開發。",
"sequencePosition": 1
},
{
"id": 11,
"name": "第 2 單元 - Hello World!",
"description": "第一行 Ruby code",
"content": "puts 'Hello World!'",
"sequencePosition": 0
},
{
"id": 12,
"name": "第 3 單元 - Rails 歷史",
"description": "Rails 的成長史",
"content": "Ruby on Rails(官方簡稱為Rails,亦被簡稱為RoR),是一個使用Ruby語言寫的開源Web應用框架,它是嚴格按照MVC結構開發,努力使自身保持簡單,使實際應用開發時的代碼更少,使用最少的組態。",
"sequencePosition": 2,
"_destroy": true
}
]
},
{
"id": 5,
"name": "第 2 章",
"sequencePosition": 1,
"_destroy": true,
"units": [
{
"id": 13,
"name": "第 4 單元",
"description": "學會放棄",
"content": "PHP is the best.",
"sequencePosition": 0
},
{
"id": 14,
"name": "第 5 單元",
"description": "不碰 Rails",
"content": "Laravel is awesome.",
"sequencePosition": 1
}
]
},
{
"name": "這是新加入的章節",
"sequencePosition": 1,
"units": [
{
"name": "新加入的單元",
"description": "新的單元",
"content": "新的單元",
"sequencePosition": 0
}
]
}
]
},
Request 欄位說明:
id
: 課程 IDname
: 課程名稱teacher
: 講師名稱description
: 課程說明chapters
: 課程章節id
: 章節 IDname
: 章節名稱sequencePosition
: 章節順序,越小越前面_destroy
: 刪除此章節units
: 章節包含的單元id
: 單元 IDname
: 單元名稱description
: 單元說明content
: 單元內容sequencePosition
: 單元順序,越小越前面_destroy
: 刪除此單元
編輯的 API 稍微複雜了一點,原則上 ID 都不能省略,課程、章節、單元的更新都是靠 id
欄位而決定更改哪個值。
從上面 Payload 來看這次會更新哪些東西
- 更新的課程講師名稱:
{
...
"name": "張三",
...
}
- 第一章節的 單元1, 單元2 交換順序,直接修改兩個 unit 裡面的
sequencePosition
...,
"units": [
{
"id": 10,
...,
"sequencePosition": 1
},
{
"id": 11,
...,
"sequencePosition": 0
},
],
...
- 刪除
第 3 單元 - Rails 歷史
單元 ,加入"_destroy": true
即可。
...,
{
"id": 12,
...,
"_destroy": true
},
...
- 刪除第 2 章,加入
"_destroy": true
即可
...,
{
"id": 5,
...,
"_destroy": true,
"units": [
...
]
},
...
- 加入新的章節及新的單元(不要有
id
參數即可)
...,
{
"name": "這是新加入的章節",
"sequencePosition": 1,
"units": [
{
"name": "新加入的單元",
"description": "新的單元",
"content": "新的單元",
"sequencePosition": 0
}
]
},
...
注意這裡 chapters 跟 units 的順序不是照 Array 的順序,而是照 sequencePosition
的值!
Example Response:
{
"id": 2,
"name": "Ruby on Rails 從入門到放棄",
"teacher": "張三",
"description": "輕鬆放棄 Ruby on Rails",
"chapters": [
{
"id": 4,
"name": "第 1 章 - 介紹",
"sequencePosition": 0,
"units": [
{
"id": 11,
"name": "第 2 單元 - Hello World!",
"description": "第一行 Ruby code",
"content": "puts 'Hello World!'",
"sequencePosition": 0
},
{
"id": 10,
"name": "第 1 單元 - Ruby 歷史",
"description": "Ruby 的成長史",
"content": "在20世紀90年代中期由日本電腦科學家松本行弘(Matz)設計並開發。",
"sequencePosition": 1
}
]
},
{
"id": 6,
"name": "這是新加入的章節",
"sequencePosition": 1,
"units": [
{
"id": 15,
"name": "新加入的單元",
"description": "新的單元",
"content": "新的單元",
"sequencePosition": 0
}
]
}
]
}
Response 欄位說明:
id
: 課程 IDname
: 課程名稱teacher
: 講師名稱description
: 課程說明chapters
: 課程章節id
: 章節 IDname
: 章節名稱sequencePosition
: 章節順序,越小越前面units
: 章節包含的單元id
: 單元 IDname
: 單元名稱description
: 單元說明content
: 單元內容sequencePosition
: 單元順序,越小越前面
DELETE /api/courses/:id
Example Response:
204 NO CONTENT
原則上會以程式碼可讀性為主,基本上如果 code 寫得看不懂,多半在 Code Review 環節會提出自己的看法或與 reviewer 討論,除非必要(如下列情況),不然不會寫。
不過註解也是需要維護的,如果修改程式碼而沒修改註解,那也失去註解的意義,另外如果看程式碼就能理解,那註解就顯得多餘。
目前下列情況下,比較會寫註解:
- 程式碼難看的出來,或 spec 文件難敘述(可能要搭配 code 比較容易看)
- 第三方套件文件沒寫如何使用
- patch 第三方套件的方法之類(可能第三方套件不符合場景,做一些額外的 patch 會寫)
- API docstring
- 功能:用來修改資料順序時,DB 自動分配欄位 sequence 值
- 範例:
Chapter.find(10).update(sequence_position: 2)
# => 會幫 Chapter id: 10 根據欲插入前後一筆資料的 sequence 值,計算出放入 position: 2 的 sequence 值,並自動更新會影響到的資料
假設有下列資料,Chapter 各欄位值如下
[#<Chapter id: 31, name: "範例章節 1", sequence: 2>, #<Chapter id: 33, name: "範例章節 3", sequence: 6>, #<Chapter id: 32, name: "範例章節 2", sequence: 7>, #<Chapter id: 34, name: "範例章節 4", sequence: 8>, #<Chapter id: 35, name: "範例章節 5", sequence: 10>, ...]
當想要把 #<Chapter id: 34, name: "範例章節 4", sequence: 8>
插入 position: 2 也就是上面 #<Chapter id: 32, name: "範例章節 2", sequence: 7>
的位置時,因為 sequence 中間的數字已經沒有多餘空間,此時 Ranked Model 會自動重新分配所有資料的 sequence。
Chapter.find(34).update(sequence_position: 2)
Chapter.select(:id, :sequence, :name).order(sequence: :asc)
# => [#<Chapter id: 31, name: "範例章節 1", sequence: -1757032075>, #<Chapter id: 33, name: "範例章節 3", sequence: -1366580502>, #<Chapter id: 34, name: "範例章節 4", sequence: -1171354715>, #<Chapter id: 32, name: "範例章節 2", sequence: -976128929>, #<Chapter id: 35, name: "範例章節 5", sequence: -195225783>, #<Chapter id: 36, name: "範例章節 6", sequence: 195225790>, #<Chapter id: 37, name: "範例章節 7", sequence: 585677363>, #<Chapter id: 38, name: "範例章節 8", sequence: 976128936>, #<Chapter id: 39, name: "範例章節 9", sequence: 1366580509>, #<Chapter id: 40, name: "範例章節 10", sequence: 1757032082>]
這次重新分配 sequence 值等於修改了 10 筆 Chapter(因為我資料只產 10 筆)的 sequence,不過現在 sequence 之間的值差異很大,所以之後把把任何一筆往中間插入,就不會影響後面幾筆資料的 sequence,只要 update 一筆 record 即可,藉此達到 Array 修改次數能跟 Linked List 一樣少,但在讀取全部資料的時候,又不用把 Linked List 的所有 Node 走訪一次。
- 功能:用來簡化測試 Model 相關設定(如:Validations、Relations),也能使測試內容看起來語義
- 範例:
it 'is not valid without a name' do
expect(course).to validate_presence_of(:name)
end
- 功能:可以透過直覺的語法,簡單快速的建立測試資料
- 範例:
- 定義 course 測試資料,請參考檔案:
spec/factories/course.rb
- 使用 course 測試資料:
- 定義 course 測試資料,請參考檔案:
let(:course) { create(:course) }
從題目設計來看,感覺並不會是直接開一個 JSON 欄位把 Chapter 跟 Unit 設成 Array 丟進去,一來 Chapter 及 Unit 內部欄位形態不容易維護(相較於開一張 chapters 跟 units 的表並定義好欄位),二來要透過 SQL 撈資料或統計時並不方便,需要看 JSON 相關 Functions 是否能做到,但往往 Nested Object 並不容易做分析、統計。
所以從一開始就決定要開三張資料表做關聯,但對於 Chapter 跟 Unit 的順序一直在思考要開一個 next_id
、previous_id
欄位來做到像 Linked List 的方式,還是要用一個 sequence number 來存放比較好。
Linked List 的好處便是要把中間任一節點拔掉,或是從中間加入任一節點時,並不會影響到後面順序的資料,只要把指向前後的值替換掉即可,但缺點便是 SQL 沒辦法直接 ORDER BY 照順序顯示資料,其二是不容易知道第 N 個是誰,必須走訪過一次。
根據上述思考的結果,自然想用類似 Array 的方式,也就是用 sequence 欄位,根據第 1 ~ N 筆資料,將其 sequence 分別設為 1 ~ N,不過缺點是,想插入或挪動一筆資料到第三個的時候,第三個開始往後的資料其 sequence 都要跟著更新,如果把 sequence 可能設為 n * 100,則在插入或挪動時需自行計算插入資料的 sequence 應該要是多少?假設把資料插入 seqeunce 為 100 及 200 中間,可能就是 150 或 101 之類,但即使如此也會因為長期更動順序下來,sequence 又有相衝,而且自己維護並不容易。
後來找到 Ranked Model 這個套件,在 sequence 會相衝的情況下,會自動把所有資料的 sequence 重新分配,且其間隔非常大,原則上重新分配 sequence number 後,要移動非常非常多次或插入非常多筆,才可能由發生 sequence 相衝,所以常態下來在更新、插入、刪除資料時,皆不會影響該順序之後的資料
在修改課程的 API 一直思考究竟要實作 PUT 可以整包丟進來直接改課程資料,還是要 PATCH 只有要調整的欄位,主要原因是 章節 跟 單元 有資料的順序性,當時並不確定 Ranked Model 是否能做到這件事,或是他其實不管位置有沒有變動,依然會每筆都更新,也就是如果我有 10 個章節,每個章節有 10 個單元,那我每次打 API 就會觸發 100 個 UPDATE requests。
基於上述情況,所以有試著了解要實作 PATCH 的話要怎麼做,後來甚至想參考 RFC 6902 的方式來實作 PATCH 方法,但這裡有幾個缺點:
- 哪些資料有變動需依賴前端根據 UI 互動,而產生這些 JSON Patch Object
- Path 難維護、解析,還要驗證 path 對應的 JSON Object 是否存在
於是最終在實作上,選擇使用 PUT 的方式,也就是呼叫更新課程 API,只要把整包資料丟進來即可。而後來使用 Rails 的 accepts_nested_attributes_for
便能輕鬆做到這件事,詳細可以參考:Active Record Nested Attributes。
不過這裡遇到的另一個問題是 Ranked Model 可以直接帶 sequence_postion: 3
把某筆章節或單元移到位置 3 上面,所以 API 都需要回傳 sequence_position
的值,但它不會檢查這筆章節或單元位置有沒有替換過,也就是當 API 送進來的 payload 其資料的 sequence_position
是多少,他就直接去打資料庫 UPDATE,就有上述的 10 個章節,每個章節 10 個單元,就會產生 100 筆 UPDATE requests 的問題,但我很好奇的其他欄位並沒有這個問題,為什麼這個會有?
後來找到答案,參考下列原始碼:
define_method "#{ranker.name}_position=" do |position|
if position.present?
send "#{ranker.column}_will_change!"
instance_variable_set "@#{ranker.name}_position", position
end
end
nested attributes 在收到參數時,會自動幫你更新資料,於是 sequence_position: 3
會類似這樣呼叫 sequence_position=(3)
修改 position,而如果欄位有變動必須呼叫 column_name_will_change!
Rails 才會幫你 save 更新,以這裡來說就是呼叫 sequence_will_change!
,不是 sequence_position_will_change!
是因為 DB 欄位是 sequence
,而 sequence_position
只是一個 setter 的 helper method。
所以知道更新原理之後,便能知道為什麼會產生 100 個 UPDATE requests,於是在 models/chapter.rb
跟 models/unit.rb
就重寫了這個方法,加上一些檢測,判斷 position 是不是真的改變了,如果變了才會呼叫 sequence_will_change!
,在 Chapter 跟 Unit 這兩個 Model 檔案加上下列程式碼:
define_method 'sequence_position=' do |position|
if position.present? && (sequence.nil? || sequence_rank.to_s != position.to_s)
send 'sequence_will_change!'
instance_variable_set '@sequence_position', position
end
end
這樣一來打課程更新 API,即使 squence_position
沒變,也不會再打 request 去 DB 更新資料了!