Giter Club home page Giter Club logo

hahow-homework's Introduction

README

我們該如何執行這個 server

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

Heroku Server

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: 課程 ID
  • name: 課程名稱
  • teacher: 講師名稱
  • description: 課程說明
  • chapters: 課程章節
    • id: 章節 ID
    • name: 章節名稱
    • sequencePosition: 章節順序,越小越前面
    • units: 章節包含的單元
      • id: 單元 ID
      • name: 單元名稱
      • 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: 課程 ID
  • name: 課程名稱
  • teacher: 講師名稱
  • description: 課程說明
  • chapters: 課程章節
    • id: 章節 ID
    • name: 章節名稱
    • sequencePosition: 章節順序,越小越前面
    • units: 章節包含的單元
      • id: 單元 ID
      • name: 單元名稱
      • 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: 課程 ID
  • name: 課程名稱
  • teacher: 講師名稱
  • description: 課程說明
  • chapters: 課程章節
    • id: 章節 ID
    • name: 章節名稱
    • sequencePosition: 章節順序,越小越前面
    • units: 章節包含的單元
      • id: 單元 ID
      • name: 單元名稱
      • 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: 課程 ID
  • name: 課程名稱
  • teacher: 講師名稱
  • description: 課程說明
  • chapters: 課程章節
    • id: 章節 ID
    • name: 章節名稱
    • sequencePosition: 章節順序,越小越前面
    • _destroy: 刪除此章節
    • units: 章節包含的單元
      • id: 單元 ID
      • name: 單元名稱
      • description: 單元說明
      • content: 單元內容
      • sequencePosition: 單元順序,越小越前面
      • _destroy: 刪除此單元

編輯的 API 稍微複雜了一點,原則上 ID 都不能省略,課程、章節、單元的更新都是靠 id 欄位而決定更改哪個值。

從上面 Payload 來看這次會更新哪些東西

  1. 更新的課程講師名稱:
{
  ...
  "name": "張三",
  ...
}
  1. 第一章節的 單元1, 單元2 交換順序,直接修改兩個 unit 裡面的 sequencePosition
  ...,
  "units": [
    {
      "id": 10,
      ...,
      "sequencePosition": 1
    },
    {
      "id": 11,
      ...,
      "sequencePosition": 0
    },
  ],
  ...
  1. 刪除 第 3 單元 - Rails 歷史 單元 ,加入 "_destroy": true 即可。
...,
{
  "id": 12,
  ...,
  "_destroy": true
},
...
  1. 刪除第 2 章,加入 "_destroy": true 即可
...,
{
  "id": 5,
  ...,
  "_destroy": true,
  "units": [
    ...
  ]
},
...
  1. 加入新的章節及新的單元(不要有 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: 課程 ID
  • name: 課程名稱
  • teacher: 講師名稱
  • description: 課程說明
  • chapters: 課程章節
    • id: 章節 ID
    • name: 章節名稱
    • sequencePosition: 章節順序,越小越前面
    • units: 章節包含的單元
      • id: 單元 ID
      • name: 單元名稱
      • description: 單元說明
      • content: 單元內容
      • sequencePosition: 單元順序,越小越前面

刪除課程

DELETE /api/courses/:id

Example Response:

204 NO CONTENT

你在程式碼中寫註解的原則,遇到什麼狀況會寫註解

原則上會以程式碼可讀性為主,基本上如果 code 寫得看不懂,多半在 Code Review 環節會提出自己的看法或與 reviewer 討論,除非必要(如下列情況),不然不會寫。

不過註解也是需要維護的,如果修改程式碼而沒修改註解,那也失去註解的意義,另外如果看程式碼就能理解,那註解就顯得多餘。

目前下列情況下,比較會寫註解:

  • 程式碼難看的出來,或 spec 文件難敘述(可能要搭配 code 比較容易看)
  • 第三方套件文件沒寫如何使用
  • patch 第三方套件的方法之類(可能第三方套件不符合場景,做一些額外的 patch 會寫)
  • API docstring

Third Party

功能相關

Ranked Model

  • 功能:用來修改資料順序時,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 走訪一次。

測試相關

Soulda Matchers

  • 功能:用來簡化測試 Model 相關設定(如:Validations、Relations),也能使測試內容看起來語義
  • 範例:
it 'is not valid without a name' do
  expect(course).to validate_presence_of(:name) 
end

Factory Bot Rails

  • 功能:可以透過直覺的語法,簡單快速的建立測試資料
  • 範例:
    • 定義 course 測試資料,請參考檔案:spec/factories/course.rb
    • 使用 course 測試資料:
  let(:course) { create(:course) }

遇到的困難、問題

1. Chapter 跟 Unit 順序到底要用什麼方式處理比較好?

從題目設計來看,感覺並不會是直接開一個 JSON 欄位把 Chapter 跟 Unit 設成 Array 丟進去,一來 Chapter 及 Unit 內部欄位形態不容易維護(相較於開一張 chapters 跟 units 的表並定義好欄位),二來要透過 SQL 撈資料或統計時並不方便,需要看 JSON 相關 Functions 是否能做到,但往往 Nested Object 並不容易做分析、統計。

所以從一開始就決定要開三張資料表做關聯,但對於 Chapter 跟 Unit 的順序一直在思考要開一個 next_idprevious_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 相衝,所以常態下來在更新、插入、刪除資料時,皆不會影響該順序之後的資料

2. 要實作 PATCH 還是要實作 PUT

在修改課程的 API 一直思考究竟要實作 PUT 可以整包丟進來直接改課程資料,還是要 PATCH 只有要調整的欄位,主要原因是 章節單元 有資料的順序性,當時並不確定 Ranked Model 是否能做到這件事,或是他其實不管位置有沒有變動,依然會每筆都更新,也就是如果我有 10 個章節,每個章節有 10 個單元,那我每次打 API 就會觸發 100 個 UPDATE requests。

基於上述情況,所以有試著了解要實作 PATCH 的話要怎麼做,後來甚至想參考 RFC 6902 的方式來實作 PATCH 方法,但這裡有幾個缺點:

  1. 哪些資料有變動需依賴前端根據 UI 互動,而產生這些 JSON Patch Object
  2. 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.rbmodels/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 更新資料了!

hahow-homework's People

Contributors

whyayen avatar

Watchers

 avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.