本文做者:網易雲音樂前端工程師 包勇明
無論 Node.js 在實際產品中的使用狀況如何,相信如今使用 Node.js 做爲服務端來開發的項目是數以百萬計的,其中絕大多數的開發人員都是前端工程師,由於 Node.js 是他們的自然語言工具。未來,愈來愈多的前端工程師會加入到 Node.js 的開發中來。前端
既然使用了 Node.js 做爲服務端開發語言,咱們確定要開發 API 接口。本文用一個示例需求,來說述一下如何高效開發高質量的服務端 API 接口。git
首先來看下需求,一共有 3 張數據庫表(數據庫是 MySQL),分別爲:github
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '惟一標識', `name` VARCHAR(50) NULL DEFAULT '' COMMENT '賬號', `email` VARCHAR(50) NULL DEFAULT '' COMMENT '郵箱', `nickname` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '真實姓名', `createTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '賬號建立時間', PRIMARY KEY (`id`), UNIQUE INDEX `uk_email` (`email` ASC) ) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='用戶表'; CREATE TABLE `project` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '惟一標識', `name` VARCHAR(100) NULL DEFAULT NULL COMMENT '名稱', `description` VARCHAR(500) NULL DEFAULT '' COMMENT '描述', `creatorId` INT UNSIGNED NOT NULL COMMENT '建立者標識', `createTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '建立時間', `deletedAt` DATETIME(3) COMMENT '刪除時間', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='項目表'; CREATE TABLE `project_user` ( `role` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用戶角色0-成員;9-管理員;10-建立者', `userId` INT UNSIGNED NOT NULL COMMENT '用戶標識', `projectId` INT UNSIGNED NOT NULL COMMENT '項目標識', `createTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '建立時間', PRIMARY KEY (`userId`, `projectId`), INDEX `idx_projectId` (`projectId` ASC) ) ENGINE=InnoDB COMMENT='項目-用戶關係表';
含義以下:面試
user
,主鍵是 id
,自增,惟一索引是 email
。project
,主鍵是 id
,自增。用戶能夠建立項目,creatorId
字段是建立者的標識。項目和用戶的關係表,它表示項目和用戶的映射關係,聯合主鍵是 userId
和 projectId
。一個項目能夠有多個用戶,用戶有不一樣的角色,用 role
字段來表示,不一樣的角色有不一樣的權限,以下:sql
用戶相關的操做不是本文要講述的重點,本文只講述如何實現和項目相關的 CRUD 接口:數據庫
由於要高效地開發高質量接口,咱們不能等有了 UI 界面纔去開發接口,開發接口和開發頁面只有作到並行開發,才能提高團隊的總體開發效率。和服務端開發打過交道的朋友應該知道,服務端在本地開發接口的時候,會使用諸如 Postman 這樣的工具來測試接口的正確性。咱們必定要儘量地作到接口開發要脫離頁面,雖然在頁面中開發接口很是直觀方便,可是它有不少的侷限性。爲了表述方便,咱們使用 eggjs 框架,數據交換格式使用 JSON,接口使用 RESTful 規範,而且都以
/api
開頭。另外,本文只介紹接口自己功能的開發,其餘諸如緩存等方面的內容不會涉及。編程
根據 RESTful 規範,接口地址是 POST /api/projects
。雖然咱們是脫離 UI 界面在開發接口,但頁面的交互邏輯是必須清楚的(通常來講,會有交互設計稿)。對於建立項目來講,通常就是一個讓用戶填寫項目信息的表單,裏面有項目的名稱和描述,用戶填寫完後,點擊「提交」按鈕提交表單數據,後端接收到數據後,在數據庫中插入一條記錄。這個就是建立項目的過程。json
客戶端發送過來的請求,會先通過一些通用的中間件處理,而後到達 Controller 層,這是編寫業務邏輯代碼的地方。咱們不能信任用戶提交過來的數據,須要對數據進行最基本的校驗。根據 project
數據庫表,name
字段是字符串類型,而且長度不能超過 100,description
字段也是字符串類型,長度不能超過 500,二者均可覺得空。後端
但對於一個實際的項目來講,name
爲空是沒有意義的,因此還應該對 name
進行非空判斷。代碼大體以下:api
// ~ /controller/project.js const createRule = { name: { type: 'string', max: 100, }, description: { type: 'string', max: 500, required: false, }, }; async create() { const ctx = this.ctx; // 接收到的 JSON 數據 const data = ctx.request.body; // 對數據進行驗證,若是驗證不經過,會直接報錯返回 ctx.validate(createRule, data); // 調用 service 方法去建立項目 const id = await ctx.service.project.create(data); }
相信不少朋友已經看出上述代碼有一個嚴重的問題,就是 data
對象中缺乏項目建立者的信息。建立者是誰?顯然,它應該是當前的登陸用戶。用戶能建立項目的前提是他已經登陸系統了。在現代的 Web 項目中,當前登陸用戶的信息通常保存在客戶端的 Cookie 中,服務端的話相應地保存在 Session 中。客戶端在發送請求的時候,請求默認就會帶上 Cookie 信息,不須要顯式地發送這個數據。服務端根據請求的 Cookie 去 Session 中取出相應的用戶信息。不過這一切的工做,框架或者中間件都幫咱們實現好了,這不是本文要講述的重點,就不展開了。
除了建立者沒有設置的問題,還有如下須要注意的地方:
name
進行校驗的時候,要去掉它兩邊的空格,畢竟名稱不能全是空格。description
無所謂,就不作處理。name
、description
和 creatorId
。注意,咱們爲了保證 Service 方法的純粹性(方便複用),不在 Service 中去 Session 裏面取當前登陸用戶的用戶數據,而是在 Controller 中將用戶傳遞給 Service。
修改後的代碼大體以下:
// ~ /controller/project.js const createRule = { name: { ... // 去掉兩邊的空格,默認是 false trim: true }, ... }; async create() { const ctx = this.ctx; // extract 是自定義的根據 rule 規則抽取有效數據的方法 const data = ctx.helper.extract(ctx.request.body, createRule); ctx.validate(createRule, data); // 設置建立者 data.creatorId = ctx.session.user.id; const id = await ctx.service.project.create(data); }
建立項目的代碼看起來已經「無懈可擊」了,但總以爲還少作了點什麼工做。
咱們來分析一下實際狀況:就算是新開發一個接口,接口代碼是逐步完善的,期間可能被重構了不少次,也就是說,存在實現了這個功能但會破壞以前已經實現好的功能,這是實際開發過程當中的廣泛現狀。
是的,咱們不能相信本身,不能相信不靠譜的人類,咱們須要工具來保障咱們以前實現的功能仍舊能夠正常工做,咱們須要給代碼的每一個邏輯分支編寫測試用例,只有在開發好接口後,若是所有測試用例都能經過,咱們才能認爲這個接口已經開發完成。
將本地開發中的測試數據以測試用例的形式保存下來,這樣的工具應該有不少,好比後端開發工程師最常使用的 Postman 工具就能夠作到。今天也向你們推薦一款工具 NEI。
NEI 是一個接口管理平臺,目前由網易雲音樂在開發和維護。在 NEI 平臺上能夠定義 HTTP 接口契約,還能夠爲定義好的接口建立測試用例,測試時會自動驗證接口響應中的字段類型是否匹配、字段是否有缺失、字段是否有多餘等等異常狀況,能爲開發人員節省不少寶貴的時間。關於 NEI 的更多信息請參考它的官方說明文檔和使用教程。
在測試本小節講解的「建立項目」接口時,不該該去關注後端的具體實現邏輯,因此從理論上來講,咱們須要建立如下測試用例:
只發送 name
字段,但它的值是非法的。非法的狀況又分三種,
name
字段,它的值是合法的。description
字段,它的值是非法的。description
字段,它的值是合法的。name
和 description
字段,它們的值都是非法的。name
和 description
字段,它們的值都是合法的。name
和 description
字段,name
的值是合法的,description
的值是非法的。name
和 description
字段,name
的值是非法的,description
的值是合法的。name
和 description
字段外,還發送了其餘字段。因爲實際代碼實現的不肯定性,徹底依賴測試用例來保障接口的正確性,從理論上來講是作不到的。實際開發過程當中也不可能會按照上面這樣的邏輯去建立測試用例。
咱們只要建立幾個關鍵的測試用例就能夠了。對於這個「建立項目」的接口,有一個正常能夠建立成功的用例再加上一到兩個因爲發送非法值致使建立失敗的用例就能夠了。
有同窗看到這裏,可能會想,要不要測試用戶沒有登陸的狀況,由於沒有登陸確定沒法建立項目。這個問題聽起來問的很是合理,實際上是無效的,由於登陸認證這個工做,在框架或者中間件層面已經解決掉了,也就是說,若是沒登陸,代碼都不會進入到 Controller,因此不用擔憂沒有登陸的問題。
項目建立完後,確定要在頁面上顯示出來,有多是單獨的項目詳情頁面,也有多是項目列表頁面,都須要用到項目數據。因此須要有查詢項目的接口,最多見的就是按項目 id 查單個項目,還有就是顯示用戶的項目列表。
按照前面咱們約定的規範:
GET /api/projects/:id
,其中 :id
叫路徑參數(Path Variable),表示項目的 id。GET /api/projects
。咱們先來分析第一種接口,即按照項目 id 查詢單個項目,它應該作到:
參數無效
。這裏須要注意的是,路徑參數自己的類型是一個字符串,它是一個字符串類型的整數,須要作下類型轉換。參數無效
。你們已經注意到,id 不能轉換成整數或者數據庫中沒有這個 id 的項目時,咱們都返回了參數無效
這個錯誤信息。可能有人會問,爲何不返回很是明確的錯誤信息呢?這樣排查問題就很快,客戶端開發人員或者產品用戶也看得更加明白。
如何返回有效的、合理的錯誤信息,實際上是一門比較大的學問,幾乎全部的研發團隊都會朝規範化的錯誤信息方向努力,但實際狀況仍是一團糟,極可能仍是由一線開發人員隨意決定的。關於這個問題,個人建議是不用返回很是明確的錯誤信息給客戶端,由於這個信息有可能會被不法分子利用,他們會根據錯誤信息來猜想代碼的具體實現從而試探可能存在的漏洞。詳細的錯誤信息應該用 Log 記錄下來,方便接口開發人員排查問題。好比你們登陸一些網站的時候,會提示賬號或者密碼不對,此時就不該該明確地告訴用戶究竟是賬號不對仍是密碼不對,接口開發人員有 Log 記錄就能夠了。
咱們再回到查詢項目這個接口。上面分析了出來兩個邏輯分支,但忘了一個很是嚴重的問題,也就是數據庫中是存在這個 id 的項目,但當前登陸用戶是沒有權限查看的。後端開發,有兩個基本概念,一個叫 認證 (authentication),一個叫 鑑權(authorization)。咱們剛纔遇到的問題就是鑑權問題,須要對資源進行鑑權。由於有不少的操做都須要判斷權限,好比查詢、更新、刪除等等,因此應該把最基本的鑑權邏輯(由於權限問題還和具體的業務邏輯有關,能抽離出來的只能是一些最基本的通用邏輯)單獨抽離成一層,在 Node.js 中咱們叫中間件。通常框架也會提供這樣的中間件,好比 eggjs 配套的 egg-cancan。
有了上述分析後,就不難寫出以下的代碼:
// ~ /controller/project.js async get() { const ctx = this.ctx; const id = parseInt(ctx.params.id, 10); if (Number.isNaN(id)) { // wrapResponse 是自定義封裝方法,此處省略實現 ctx.body = this.wrapResponse(id, 'BAD_REQUEST'); } else { // canReadProject 方法會去調用鑑權中間件的方法,此處省略實現 const canRead = await this.canReadProject(id); if (canRead) { const project = await ctx.service.project.get(id); ctx.body = this.wrapResponse(project); } else { ctx.body = this.wrapResponse(id, 'BAD_REQUEST'); } } }
咱們再來看第二種接口,也就是查詢用戶的項目列表。首先要明白業務需求是什麼,也就是用戶能夠見到哪些項目。咱們在最開始已經寫明瞭:只有項目的成員、管理員、建立者對該項目可見。項目和用戶的關係是用了一張單獨的表 project_user
來保存的,咱們再回顧下這張表的設計:
CREATE TABLE `project_user` ( `role` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用戶角色0-成員;9-管理員;10-建立者', ... )
請注意 role
字段的註釋說明,它的值是一個數字,每種數字表示不一樣的角色,好比 10
表示是這個項目的建立者。細心的朋友可能已經注意到,咱們前面在分析「建立項目」接口時,並無分析到在建立項目的時候,在往 project
表中插入一條記錄的同時還要往 project_user
表插件一條表示項目和建立者的關係記錄。有朋友可能會反駁說這條記錄實際上是多餘的,由於 project
表中已經有了 creatorId
字段來記錄項目和建立者的關係了。
那麼到底需不須要往 project_user
表插入這條記錄呢?這多是一個仁者見仁智者見智的問題。就咱們今天講解的這個需求,按照 project_user
表中 role
的字段註釋,最好是插入這條記錄,有時候適當地冗餘一些數據多是件好事,說不定還能夠提高數據庫的查詢性能。
根據上述分析,查詢用戶的項目列表,須要查詢兩張表,首先是根據用戶 id 去 project_user
表把全部 userId
爲用戶 id 的項目 id(也就是projectId) 查出來,結果是一個數組集合,而後根據這個項目 id 集,批量去 project
表把項目查出來就能夠了。具體的代碼就不演示了。
另外有一個細節須要注意的是,project
表中有一個 deletedAt
字段,表示項目的刪除時間。刪除項目,咱們選擇了使用字段標記方案,而不是直接物理刪除數據。在把項目數據返回給客戶端的時候,須要過濾掉這個 deletedAt
字段,這是一個後端內部邏輯使用的字段,不必給客戶端開發者看到。這是一個通用的處理邏輯,封裝成一個方法就能夠了。
最後不要忘了,咱們還須要給這兩個接口添加測試用例:
根據 id 查詢單個項目的測試用例:
查詢用戶的項目列表的測試用例:
首先仍是分析業務需求,修改這種操做,和具體的業務邏輯關係很是大,好比能夠限制只有項目建立者能夠修改項目,也可讓全部項目成員均可以修改項目。咱們的需求最開始也已經描述過了「只有項目的管理員和建立者能夠修改項目」。
根據 project
表,項目的名稱和描述能夠被修改,須要注意的是,對它們的校驗,和建立項目的邏輯須要保持一致,好比名稱不能是空字符串。
咱們還有一張表 project_user
表,因此應該有這麼一張頁面,能夠在上面設置項目的成員,好比將某個用戶添加到項目中來,或者將某個用戶設置爲管理員。雖然更合理的作法是給這種操做開發單獨的接口,以和「修改項目的名稱和描述」這個操做作下區分,代碼寫起來也更清晰明瞭。
本節要實現的「修改項目」接口會支持以上兩種情形。根據以前的分析,接口地址應該是 PATCH /api/project/:id
。下面咱們來分析代碼邏輯:
name
,就不該該去更新它,因爲咱們要實現的接口可能會涉及到兩張表,這樣一來,還能減小數據庫操做,要知道操做數據庫是很是昂貴的,和操做 DOM 對象相似,能避免就儘可能避免。{ "members": [], "admins": [] }
這樣,role
這個信息對客戶端能夠作到透明,客戶端開發不須要去設置這個值,能省點溝通成本就省點溝通成本,要否則還須要告訴客戶端在添加成員的時候,要把 role
字段的值設置爲 0,設置管理員的時候要把 role
的值設置爲 9,這不但須要溝通成本,並且容易引入 Bug。
若是客戶端發送過來的數據包含了 members
或者 admins
字段,此時就須要去更新 project_user
表,有如下情形須要考慮:
members
是一個空數組,它表示刪除了全部的項目成員。members
是非空數組,則須要計算出哪些成員被刪除,哪些成員被添加,而後再批量更新數據庫。admins
和 members
的邏輯同樣,再也不贅述。members
和 admins
數組中。雖然經過 UI 界面操做能夠避免這種狀況,但服務端不該該徹底信任客戶端發送過來的數據,由於請求數據是能夠經過工具來僞造的。顯然,members
和 admins
中也不該該出現項目的建立者。經過上述分析,代碼就不難實現了,實際代碼較長,這裏就不貼出來了。
一樣的,須要爲這個接口添加適當的測試用例,比較關鍵的有:
members
、admins
數據合法。members
、admins
中出現沒有在系統中註冊的用戶。members
、admins
中出現同個用戶。members
、admins
中出現了建立者。根據前面的分析,刪除項目的接口地址是 DELETE /api/projects/:id
,而且咱們不是物理刪除記錄,而是去給 deletedAt
字段賦值。這個字段有值表示項目已經被刪除。
服務端的 Project Controller 代碼,只要調用 Project Service 的 update
方法就能夠了,固然別忘記對項目鑑權,咱們的需求已經規定只有項目的建立者才能刪除項目,最終代碼大體以下:
// ~ /controller/project.js async remove() { const ctx = this.ctx; const id = parseInt(ctx.params.id, 10); if (Number.isNaN(id)) { ctx.body = this.wrapResponse(id, 'BAD_REQUEST'); } else { const canDelete = await this.canDeleteProject(id); if (canDelete) { // 刪除時更新 deletedAt 字段,不是真正物理刪除 const result = await ctx.service.project.update({ id, deletedAt: new Date() }); if (result.success) { ctx.body = this.wrapResponse({ id }); } else { ctx.body = this.wrapResponse( {}, result.resType || 'SERVER_ERROR' ); } } else { ctx.body = this.wrapResponse({}, 'BAD_REQUEST'); } } }
上述代碼只更新了 deletedAt
字段。還有一個細節問題咱們沒有考慮,就是刪除項目的時候,要不要把 project_user
中和這個項目相關的記錄所有刪除?否則項目被刪除了,這些記錄也沒用了,留着不是佔用數據庫空間嗎?若是是一個用戶量很是大的產品,這個問題是必需要處理的,那時可能都不會使用 deletedAt
字段來標記項目是否被刪除的狀態,極可能是其餘方案了。對於小項目來講,刪項目的時候,刪不刪 project_user
的記錄,都無所謂,看實際狀況現作決定便可。通常來講最好是別刪,能夠減小恢復項目時的工做量。
本文演示的「項目更新」接口已經處理了項目成員及管理員的邏輯,因此要刪除這些記錄,須要修改的代碼也不多:
const result = await ctx.service.project.update({ id, deletedAt: new Date(), // 設置成空數組表示將成員記錄所有刪除 members: [], admins: [] });
最後,須要給這個接口添加兩個關鍵的測試用例:
本文較爲詳細地分析了開發服務端 CRUD 接口的過程,須要考慮的點仍是很是多的,這和前端工程師的開發思惟有較大的不一樣,特別是資源鑑權、數據合法性校驗、關鍵測試用例等等,須要花費較大的精力。
無論是前端工程師仍是後端工程師,想要高效地開發高質量的 API 接口,都務必作到如下幾點:
不少剛工做不久的人問我應該如何提高本身的能力?由於編程語言層面的問題他們以爲都已經掌握了。咱們試想一下,在評估一我的能力的時候,會考慮哪些因素?一個是知識面,這個通常在面試環節就能被問出來。第二個即是實際作需求的時候,考量的維度有:問題難度、引發的 Bug 數量、和同事的協做等等,這些都是能夠證實本身能力的地方,若是都作得很好,在同事的心中就是一個能力強的人。就好比本文所講述的 API 接口開發,若是開發出來的 API 接口實現得很是正確也沒有漏洞,那在客戶端同事的心中你就是一位能力強的人。
在完成本文初稿的時候,你們都以爲這是一篇教你們如何開發 API 接口的教程,由於寫得很是詳細,基本上是到了手把手的地步。這個目的是首要的,但並非本文真正的目的。本文的最終目的是想證實把一件事情作到極致須要花費怎樣的努力,同時也順便回答瞭如何提高我的能力的問題。
本文發佈自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!