Link Search Menu Expand Document

3. 建立 API 伺服器

3-1 目標

要實作一個訂票系統 API 伺服器,可以提供給手機 iOS, Android 應用程式,或是一個開放平臺給別的開發者串接使用。

功能包括:

  • 可以查詢有哪些列車
  • 可以查詢特定列車有哪些空位
  • 可以訂票,並得到一組訂票號碼(亂數產生)
  • 根據訂票號碼,可以查詢訂票資料
  • 根據訂票號碼,可以修改訂票資料
  • 根據訂票號碼,可以取消訂票

Models 會有 Train 和 Reservation。

根據上述的需求,我們需要設計對應的 Web API,首先需要決定網址和 HTTP 方法:

  • GET /trains
  • GET /trains/{列車編號}
  • GET /reservations/{訂票號碼}
  • POST /reservations/{訂票號碼}
  • PATCH /reservations/{訂票號碼}
  • DELETE /reservations/{訂票號碼}

3-2 什麽是 REST API

在 Rails 的路由中,我們已經學過 RESTful 的概念:

  • GET 讀取資料
  • POST 新增資料
  • PATCH 修改資料
  • DELETE 刪除資料

在設計Web API 的時候,我們也是用一樣的大原則進行設計,這種設計原則就叫做REST:圍繞在Model(在REST中叫做Resource 資源)來建構CRUD 的操作,其中網址會是名詞,用不同的HTTP 方法來區分CRUD 操作。

不過,有時候有些操作也不完全是 CRUD 的概念,例如批次更新的操作,可能就會設計成

POST /topics/bulk_update

這種風格叫做 RPC(remote procedure call: 遠端程序呼叫),網址是動詞,POST 表示呼叫。

又例如訂閱可以有兩種設計風格,第一種是 REST 風格,

POST /topics/{topic_id}/subscription 和 DELETE /topics/{topic_id}/subscription/{id}

其中 subscription 是名詞,用 POST 表示新增、用 DELETE 表示刪除。

或第二種 RPC (remote procedure call: 遠端程序呼叫) 風格:

POST /topics/{topic_id}/subscribe 和 POST /topics/{topic_id}/unsubscribe

subscribe 和 unsubscribe 是動詞,用 POST 表示執行。

RPC 風格也是常見的設計,目前設計 Web API 的主流是以 REST 風格為主。但無論是 REST 或 RPC 風格,*切記 HTTP GET 和 POST 方法不能用錯:GET 只能單純讀取資料,不應該修改資料。而 POST 則是執行某個操作,會修改到伺服器的資料。

這是因為互聯網都會假設 GET 是可以重復讀取並快取的,而 POST 不行。因此搜尋引擎只會用 GET 抓資料,像這篇文章就鬧了笑話,這個人用 GET 來刪除資料,造成Google 爬蟲一爬就不小心刪除了,他還以為是 Google 故意駭他…lol 另外,像瀏覽器的表單送出是用POST,如果我們在action 中不redirect (也就是讓瀏覽器去GET 另一頁),而是直接render 返回,那麽如果用戶重新整理畫面的話,瀏覽器會跳出以下的警告視窗,要求用戶確認是否再POST 一次,因為這可能會造成重復操作(重復新增)。如果是 GET 的話,重新整理就不會有這種警告了。

images

3-3 建立 Models

執行 rails g model train

修改 db/migrate/201703XXXXXXXX_create_trains.rb


 class CreateTrains < ActiveRecord::Migration[5.0]
   def change
     create_table :trains do |t|
+      t.string :number, :index => true # 列車號碼
       t.timestamps
     end
   end
 end

執行 rails g model reservation

修改 db/migrate/201703XXXXXXXX_create_reservations.rb


 class CreateReservations < ActiveRecord::Migration[5.0]
   def change
     create_table :reservations do |t|
+      t.string :booking_code, :index => true
+      t.integer :train_id, :index => true
+      t.string :seat_number, :index => true # 座位號碼
+      t.integer :user_id, :index => true
+      t.string :customer_name
+      t.string :customer_phone
       t.timestamps
     end
   end
 end

執行 rake db:migrate 建立數據表。

修改 app/models/train.rb 加上關聯和資料驗證,以及一個 available_seats 方法回傳可以被預訂的座位號碼:

   class Train < ApplicationRecord
+    validates_presence_of :number
+    has_many :reservations
+
+    def available_seats
+      # TODO: 回傳有空的座位,這裡先暫時固定回傳一個陣列,等會再來處理
+      ["1A", "1B", "1C", "1D", "1F"]
+    end
   end

以及修改 app/models/reservation.rb 針對每筆 Reservation 在新建的時候,亂數產生一個 booking_code 這是訂票號碼,訂好票之後,可以用這個號碼來做修改或取消訂票:


 class Reservation < ApplicationRecord
+  validates_presence_of :train_id, :seat_number, :booking_code
+  validates_uniqueness_of :seat_number, :scope => :train_id
+
+  belongs_to :train
+
+  before_validation :generate_booking_code, :on => :create
+
+  def generate_booking_code
+    self.booking_code = SecureRandom.uuid
+  end
 end

編輯 db/seeds.rb 加入種子資料:

Train.create!( :number => "0822")
Train.create!( :number => "0603")
Train.create!( :number => "0826")
Train.create!( :number => "0642")

執行 rake db:seed 就有這些種子列車資料了。

3-4 配置路由

根據上述的 API 設計,我們來設置 config/routes.rb

 Rails.application.routes.draw do
+ namespace :api, :defaults => { :format => :json } do
+   namespace :v1 do
+     get "/trains"  => "trains#index", :as => :trains
+     get "/trains/:train_number" => "trains#show", :as => :train
+
+     get "/reservations/:booking_code" => "reservations#show", :as => :reservation
+     post "/reservations" => "reservations#create", :as => :create_reservations
+     patch "/reservations/:booking_code" => "reservations#update", :as => :update_reservation
+     delete "/reservations/:booking_code" => "reservations#destroy", :as => :cancel_reservation
+   end
+ end

 # (略)
 end

其中兩層的namespace 會讓網址前增加/api/v1/、Controller 的目錄多兩層放在app/controllers/api/v1/ 下、Controller 類的名字前面增加Api::V1 ,變成Api::V1::ReservationsController、路由方法變成api_v1_XXXXX_pathapi_v1_XXXXX_url

而其中post "/reservations" => "reservations#create", :as => :create_reservations的意思是

客戶端送出 POST /api/v1/reservations 時,會進入 Api::V1::ReservationsController 的 create 方法,而 as 參數的意思是產生這個地址的路由方法叫做 api_v1_create_reservations_path

最後,:defaults => { :format => :json } 意思是預設客戶端要求的是 JSON 格式,本來的預設值是 HTML。如果沒有改這個的話,你必須在網址最後面加上 .json 來指定客戶端要求的格式,例如 GET /api/v1/trains.json

3-5 產生 Api controller

接著來製作 Controller,首先產生 ApiController。在 Terminal 輸入:

rails g controller api --no-assets

修改 app/controllers/api_controller.rb

- class ApiController < ApplicationController
+ class ApiController < ActionController::Base
+   skip_before_action :verify_authenticity_token
end

我們會把 API 用途的 controller 都繼承自 ApiController,而不是 ApplicationController。這是因為 API 場景不需要 CSRF 瀏覽器安全檢查機制。什麼是 CSRF 詳見Rails 網站安全這章的說明。

Rails 5.2 之後預設打開了 CSRF 瀏覽器安全檢查機制,因此這裡用 skip_before_action :verify_authenticity_token 特別關掉它。Rails 6 之前,則是在 ApplicationController 之中放了 protect_from_forgery with: :exception 打開這個檢查。

3-6 實作 GET /trains

執行

rails g controller api::v1::trains –no-assets

編輯 app/controller/api/v1/trains_controller.rb


- class Api::V1::TrainsController < ApplicationController
+ class Api::V1::TrainsController < ApiController

+  def index
+    @trains = Train.all
+    render :json => {
+      :data => @trains.map{ |train|
+        { :number => train.number,
+            :train_url => api_v1_train_url(train.number)
+        }
+      }
+    }
+  end
+
+  def show
+    @train = Train.find_by_number!( params[:train_number] )
+
+    render :json => {
+      :number => @train.number,
+      :available_seats => @train.available_seats
+    }
+  end

 end

其中 render :json => 某個變數 的語法,會把變數轉成 JSON 字串輸出。這裡也不需要準備 View .erb 檔案。

因為這兩個 API 都是用 HTTP GET 讀取,我們可以直接打開瀏覽器,瀏覽 http://localhost:3000/api/v1/trains 就是用 GET 讀取資料。

image

請先裝 Chrome extension JSON Formatter,就會有排版整齊的輸出。

image

瀏覽其中一筆列車的話 http://localhost:3000/api/v1/trains/0822

image

你也可以用 Postman 來看:

image

如果程序出錯的話,在Postman會看到HTML原始碼,反而很難看出錯誤的地方,這個時候可以再點 Preview 就能看到錯誤網頁,或者是回到 Terminal 看伺服器的 log。

3-7 實作 reservations 定位

接下來實作訂位:

執行 rails g controller api::v1::reservations --no-assets

編輯 app/controller/api/v1/reservations_controller.rb


- class Api::V1::ReservationsController < ApplicationController
+ class Api::V1::ReservationsController < ApiController

+  def create
+    @train = Train.find_by_number!( params[:train_number] )
+    @reservation = Reservation.new( :train_id => @train.id,
+                                    :seat_number => params[:seat_number],
+                                    :customer_name => params[:customer_name],
+                                    :customer_phone => params[:customer_phone] )
+
+    if @reservation.save
+      render :json => { :booking_code => @reservation.booking_code,
+                        :reservation_url => api_v1_reservation_url(@reservation.booking_code) }
+    else
+      render :json => { :message => "訂票失敗", :errors => @reservation.errors }, :status => 400
+    end
+  end

end

這樣就完成了,要怎麽測試呢? 如果直接瀏覽 http://localhost:3000/api/v1/reservations 的話,瀏覽器會用 GET 而不是 POST,這時候必須用 Postman 的表單進行測試,輸入網址,方法選 POST,在 Body 中輸入參數,按下送出之後,得到下麵的結果就表示完成了:

image

3-8 完成其他部分

接下來讓我們在 Api::V1::ReservationsController 完成其他 API:

查詢


+ def show
+   @reservation = Reservation.find_by_booking_code!( params[:booking_code] )
+
+   render :json => {
+     :booking_code => @reservation.booking_code,
+     :train_number => @reservation.train.number,
+     :seat_number => @reservation.seat_number,
+     :customer_name => @reservation.customer_name,
+     :customer_phone => @reservation.customer_phone
+   }
+ end

用 Postman 測試,用 GET http://localhost:3000/api/v1/reservations/訂票號碼

image

修改


+ def update
+   @reservation = Reservation.find_by_booking_code!( params[:booking_code] )
+   @reservation.update( :customer_name => params[:customer_name],
+                        :customer_phone => params[:customer_phone] )

+   render :json => { :message => "更新成功" }
+ end

用 Postman 測試,用 PATCH http://localhost:3000/api/v1/reservations/訂票號碼

image

取消


+ def destroy
+   @reservation = Reservation.find_by_booking_code!( params[:booking_code] )
+   @reservation.destroy

+   render :json => { :message => "已取消定位" }
+ end

用 Postman 測試,用 DELETE http://localhost:3000/api/v1/reservations/訂票號碼

image

完成 Train#availale_seats 方法

在前述步驟中的 Train#available_seats 讓我們完成它:

 class Train < ApplicationRecord

   validates_presence_of :number
   has_many :reservations

+  # 產生所有位置從 1A~6C
+  # ["1A", "1B", "1C", "2A", "2B", "2C", "3A", "3B", "3C",
+  #  "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "6C"]
+  SEATS = begin
+    (1..6).to_a.map do |series|
+      ["A","B","C"].map do |letter|
+       "#{series}#{letter}"
+      end
+    end
+  end.flatten

   def available_seats
-    # TODO: 回傳有空的座位,這裡先暫時固定回傳一個陣列,等會再來處理
-    ["1A", "1B", "1C", "1D", "1F"]

+    # 所有 SEATS 扣掉已經訂位的資料
+    return SEATS - self.reservations.pluck(:seat_number)
   end

 end

3-9 解說

以下補充說明設計的緣由:

HTTP response code

HTTP 的 response 都會有一個 HTTP 狀態碼,最基本常見會用到的有:

  • 200 成功(這是預設值)
  • 400 客戶端參數錯誤,例如資料驗證失敗,必填的資料沒有填等等
  • 401 Unauthorized 要求登入
  • 403 有登入但是權限不夠
  • 404 找無此資源
  • 500 伺服器錯誤

例如我們在訂位失敗的時候,多指定了 :status => 400

拆開 URL 和 controller

在路由中我們使用了 namespace :apinamespace :v1,讓 API 使用的 controller,和網頁用戶的 controller 拆開,網址用 api/v1 也表示這是 API 專用的版本。

原因是

  1. API 的操作流程,和網頁用戶的操作流程是迥異的,拆開來有助於維護
  2. api/v1 是因為需要保持向下相容性。我們不能隨意變更 API 格式,不然客戶端的程序會壞掉。不像 Web 程式可以隨時升級,行動裝置的 App 上架需要時間,因此需要保持 API 的向下相容性。

怎樣叫做破壞向下相容的變更呢? 例如修改網址、修改 JSON key 的名稱、變更 value 的型態等等。如果只是新增 key 是相容的。也因此不是每次有改 API 一定都需要遞增版本。只有破壞性的變更才需要改成 v2。

router.rb 寫法

我們也可以改用大家原本熟悉的 resources 寫法:

namespace :api, :defaults => { :format => :json } do
  namespace :v1 do
    resources :trains, :only => [:index, :show]
    resources :reservations, :ony => [:show, :create, :update, :destroy]
  end
end

不過,resources 寫法是 Rails 獨有的,因此在撰寫 API 說明文件的時候,我們會逐條每個 API 都列出來,這樣跟我們合作的 iOS 、Android 工程師或第三方工程師才看得懂怎麽呼叫 Web API。


Copyright © 2010-2022 Wen-Tien Chang All Rights Reserved.