原創文章,轉載請註明:轉載自Keegan小鋼
並標明原文連結:http://keeganlee.me/post/practice/20160812
微信訂閱號:keeganlee_me
寫於2016-08-12
確定功能需求
概述篇釋出出去後,收到很多人的大力支援,也收到了幾點關於功能需求的建議,主要在於幾點:
- 只有微信登入在App Store那邊稽核很可能通不過;
- 呼叫微信獲取使用者頭像和暱稱的介面需要企業微訊號才行;
- 就算微信登入也存在需要修改頭像和暱稱的需求。
關於第1點,細想一下就知道,只有第三方賬號登入的確是通不過稽核的。因為提交稽核時必須提供測試賬號給App Store的稽核人員。稽核人員是不會使用自己的賬號進行測試的,不管是自己的微信、微博還是手機號。之前我是掉過這個坑的,提交了一款以手機號+簡訊驗證碼登入的App,但沒有提供測試賬號,結果被打回來了。所以,還是需要建立自己的使用者體系,這一點無法偷懶了。
關於第2點,則是因為微信對這部分介面做了許可權控制,只有通過了開發者資質認證才有權開通此介面。但微信的開發者資質認證並不支援個人開發者。另外,還要交每年300元的稽核費用。其實,未認證的開發者建立的App只有分享的許可權,根本沒有登入的許可權。所以,微信登入這條路根本通不了。因此,我決定不用微信登入了,改用Github登入。畢竟,面向的使用者群是程式猿,而程式猿基本是人手一個Github賬戶。沒有Github賬戶的,稱不上合格的程式猿。也不考慮再加入微博、QQ、Facebook、Twitter等社交賬戶登入。因為選擇太多容易混亂,我自己在某些平臺登入時,就經常不記得上一次是用哪個賬戶登入的。
關於第3點,毫無疑問,修改頭像和暱稱的功能需要保留。
因此,最終的功能需求應該如下:
- 手機號 + 簡訊驗證碼註冊
- 手機號 + 簡訊驗證碼登入
- Github登入
- 上傳圖片
- 修改頭像
- 修改暱稱
- 設定使用者技術棧標籤
- 獲取同棧之猿的內容列表
- 獲取關注之猿的內容列表
- 獲取同棧的使用者列表(未有關注之猿時獲取)
- 釋出問題
- 釋出分享
- 關注某條內容
- 取消關注內容
- 獲取內容的評論列表
- 新增評論
- 回覆評論
- 點贊評論
- 關注某使用者
- 取消關注某使用者
- 獲取某人詳細資料
- 獲取某人的釋出內容
- 獲取某人關注的人
- 獲取某人的粉絲列表
- 獲取我的訊息
- 提交意見反饋
- 退出登入
需求確定,接著就可以開始設計API了。
REST API
關於什麼是REST,我就不在這裡贅述了,直接推薦REST作者的經典論文:
下面我只想用一些例項描述幾種架構風格在API定義方面的不同。
假如現在要定義登入、退出登入、註冊、查詢使用者資料的介面,那麼,可以這樣定義:
介面 | 方法 | Endpoint |
---|---|---|
登入 | POST | /user/login |
退出登入 | POST | /user/logout |
註冊 | POST | /user/register |
查詢使用者資料 | GET | /user/queryInfo |
使用這種風格的貌似很多。也有些不是在URI中定義介面,而在引數中用method或action之類的引數名區分不同介面,示例如下:
介面 | 方法 | 引數 |
---|---|---|
登入 | POST | method=login |
退出登入 | POST | method=logout |
註冊 | POST | method=register |
查詢使用者資料 | GET | method=queryUserInfo |
最後,再看下面這種介面的定義:
介面 | 方法 | Endpoint |
---|---|---|
登入 | POST | /sessions |
退出登入 | DELETE | /sessions/{session_id} |
註冊 | POST | /users |
查詢使用者資料 | GET | /users/{user_id} |
這三種定義有什麼區別呢?其實,前面兩種可以認為都是 RPC(Remote Procedure Call) 風格的,而最後這種則可認為是 REST 風格的。
RPC和REST區別在哪呢?
最直接的區別就是:RPC抽象的是過程,REST抽象的是資源。過程是以動詞為核心,而資源是以名詞為核心。也可以簡單類比為:RPC是程式導向的,REST是物件導向的。
從上面的例子就可以看出,前面兩種定義,每個介面分別用了一個操作性的詞語去定義;而最後一種定義,登入和退出登入都屬於 /session 資源,註冊和查詢使用者資料都屬於 /user 資源,然後分別用POST、DELETE、GET等方法對同個資源定義不同操作。
我發現,還有些定義是RPC-REST混合的,例如,可能會這樣子定義:
介面 | 方法 | Endpoint |
---|---|---|
登入 | POST | /users/login |
退出登入 | POST | /users/logout |
註冊 | POST | /users/register |
查詢使用者資料 | GET | /users/{user_id} |
如果再加個修改使用者資料的介面,可能是這樣子的:
介面 | 方法 | Endpoint |
---|---|---|
修改使用者資料 | POST | /users/{user_id}/update |
給我的感覺就是:好混亂!這種大部分都是在對REST有過很初淺的瞭解,但卻缺少正確理解的情況下做出的設計。或者是對於部分介面不知道該如何抽象為資源,所以就直接用RPC方式去定義了。
其實,使用REST風格設計API,我覺得難點就在於如何抽象資源。使用RPC則相對容易很多。這時,也許有人就會提出疑問了。既然使用RPC比用REST更容易抽象出介面,那為何還要用REST呢?要解答這個疑問,可以從程式導向和麵向物件的角度去思考。我們知道,程式導向的思考方式處理問題更直接簡單,那為什麼我們還要使用物件導向呢?至於這個問題的答案,我就不再展開了。
API定義
本專案的API是打算使用REST方式定義的。那麼,首先,就是資源的Endpoint定義。根據前面的功能需求整理出以下資源,可能會有些遺漏:
Endpoint | 資源 |
---|---|
/files | 檔案 |
/files/{file_id} | 某個檔案 |
/sessions | 會話 |
/sessions/{session_id} | 某個會話 |
/users | 使用者 |
/users/{user_id} | 某使用者 |
/users/{user_id}/posts | 某使用者釋出的內容 |
/users/{user_id}/following | 某使用者關注的人 |
/users/{user_id}/followers | 某使用者的粉絲 |
/posts | 釋出的內容 |
/posts/{post_id} | 某條內容 |
/posts/{post_id}/comments | 某條內容的評論 |
/me | 當前使用者 |
/me/posts | 我釋出的內容 |
/me/stars | 我星標的內容 |
/me/following | 我關注的人 |
/me/followers | 我的粉絲 |
/me/messages | 我的訊息 |
定義資源的Endpoint時,需要分清楚不同資源的層級關係。一個定義良好的URI,應該具有可讀性,即從URI本身即可知道它所代表的資源。另外,對於URI中的一些變數值,如{file_id}、{session_id}、{user_id}、{post_id}等,在傳值的時候必須確保不能為空,可以設定預設值。
接著,就需要對每個資源定義操作的方法了。我傾向於使用以下四個方法:
方法 | 描述 | 示例 | 示例說明 |
---|---|---|---|
POST | 建立新資源 | /posts | 建立新內容 |
GET | 查詢資源 | /posts | 查詢內容列表 |
PUT | 修改資源 | /posts/{post_id} | 修改某條內容 |
DELETE | 刪除資源 | /posts/{post_id} | 刪除某條內容 |
不過,並不是所有資源都會開放這四個方法。例如,對/post是不開放PUT和DELETE方法的。對於以上資源,具體需要定義哪些方法,這裡就不再列出來了。
然後,還要加入版本控制。畢竟,介面不是一成不變的,需要不斷改動升級版本應對各種變化。那麼,版本號要加在哪裡好呢?關於這個問題,網上有很多討論,有些人喜歡直接加在URI中,像這樣:
http://api.domain.com/v2.1/posts複製程式碼
有些人喜歡加在引數裡,像這樣:
http://api.domain.com/posts?version=2.1複製程式碼
也有些人喜歡加在Header裡,像這樣:
Accept: application/json;version=2.1複製程式碼
或者自定義Header
api-version: 2.1複製程式碼
不喜歡第一種方式的人,大部分理由是,URI表示資源,應該與版本無關。而第二種方式和第一種方式本質上是一樣的。大部分人建議使用第三種方式。不過,發現好多開放API都是採用第一種方式。在我看來,加在哪裡其實影響不大。在本專案中,我打算和大部分開放API一樣採用第一種方式即可。另外,如果版本號不提供,則預設為採用最新版本的介面。
最後,再定義下響應的資料協議。初期打算使用JSON,後期可能會考慮使用Protocol Buffers。資料結構則如下:
{
code:200,
message: "success",
data: { key1: value1, key2: value2, ... }
}複製程式碼
- code: 錯誤碼
- message: 描述資訊,成功時為"success",錯誤時則是錯誤資訊
- data: 成功時返回的資料,型別為物件或陣列
之前,我是喜歡將請求狀態碼和業務錯誤碼分開處理的。因此,這裡的code我之前喜歡將其定義為業務錯誤碼。但是,如果按照REST風格來設計,還是有統一的code更合適。因此,我這次嘗試下改變習慣。
API安全設計
安全設計方面,首先,我打算全面使用HTTPS。使用HTTPS,雖然犧牲了效能,但可以解決大部分安全問題。另外,蘋果在之前的WWDC上就已宣佈,從2017年1月1日起,所有iOS應用將強制使用HTTPS。這其實也意味著,從2017年起,所有App都將會使用HTTPS,不只是iOS。除非有個別比較奇葩,非要搞HTTP和HTTPS兩套。至於HTTPS的優化,則需要慢慢搞了。至於證書,自己弄個自簽名證書即可。後期需要支援Web版的話再找個靠譜的CA註冊證書。
其次,使用者鑑權方面則打算採用Token方式。使用者登入之後分配一個accessToken和一個refreshToken,accessToken用於發起使用者請求,refreshToken用於更新accessToken。accessToken會設定有效期,可以設為24小時。而使用者退出登入之後,accessToken和refreshToken都將作廢。重新登入之後會分配新的accessToken和refreshToken。
然後,我還打算在App層級分配AppKey和AppSecret,Android和iOS分別分配一對。每次向服務端傳送請求時,AppKey都必須帶上,服務端會對相應的AppKey進行校驗。而AppSecret則需要安全儲存在客戶端,也不能在網路上進行傳輸,防止洩露。AppSecret只用於加密一些安全性級別較高的資料,以及為URL生成簽名。URL簽名演算法步驟如下:
- 將所有引數按引數名進行升序排序;
- 將排序後的引數名和值拼接成字串stringParams,格式:key1value1key2value2...;
- 在上一步的字串前面拼接上請求URI的Endpoint,字串後面拼接上AppSecret,即:stringURI + stringParams + AppSecret;
- 使用AppSecret為金鑰,對上一步的結果字串使用HMAC演算法計算MAC值,這個MAC值就是簽名。
另外,如果為了再加強安全性,參與簽名的引數列表中可以再新增個timestamp欄位,值為傳送請求時的時間戳,每次請求的時間戳都將不同,這樣不止增加了簽名的不可預測性,也可以防止重放攻擊。服務端收到請求後先檢查時間戳離當前時間是否過久,如果過久則不予處理。不過,這還涉及到客戶端和服務端時間同步的問題。這個很難保持一致,就算使用長連線不斷獲取伺服器時間,也會因為網路原因而存在延遲,而且在行動網路延遲可能還會比較高。
還有另一種方案,就是使用nonce欄位,值為一個較長的隨機數,而不是時間戳。每次請求的隨機數也都會不同,可以達到同樣的效果。不過,採用這種方案的話,那伺服器需要儲存以前傳送過的nonce。每次收到請求後先檢查nonce是否已存在,存在則不予處理。這樣,時間久了,nonce的量將會非常大。也有一種優化方案,那就是每次請求的nonce值由服務端生成併傳送給客戶端。即是說,客戶端每次傳送正式請求之前,需要先向服務端請求nonce值。這樣的話,服務端則可以在有請求過來的時候才生成nonce,請求處理完之後則可以刪除nonce。不過,弊端也很明顯,本來一次的請求變成了兩次。
不過,在我的這個專案中,初期我只要求加強簽名的不可預測性即可,而nonce方案具備更高的不可預測性。因此,我將採用的方案是:在客戶端自己生成nonce,但服務端不儲存nonce,只要檢查請求中是否存在nonce即可。
URL簽名在每次傳送請求時都需要附加在引數中,服務端接收到請求後會使用同樣的簽名演算法計算簽名值,只有服務端計算出來的簽名值和接收到的簽名值一致時才認為請求是安全的。
寫在最後
自此,API部分的設計就完成了。在此總結一下:
- 採用REST風格定義API,介面抽象成對資源的操作;
- 新增API版本控制,版本號嵌在URL中;
- 響應統一使用code、message、data的JSON資料格式;
- 全站採用HTTPS;
- 使用Token方式對使用者鑑權;
- 使用AppKey方式對應用鑑權;
- 使用URL簽名對請求鑑權;
- 引數中新增nonce值增強簽名的不可預測性。
掃描以下二維碼即可關注訂閱號。