REST API 是當(dāng)今可用的最常見的 Web 服務(wù)類型之一。它們?cè)试S包括瀏覽器應(yīng)用程序在內(nèi)的各種客戶端通過 REST API 與服務(wù)器通信。因此,正確設(shè)計(jì) REST API 非常重要,這樣我們以后就不會(huì)遇到問題。我們必須考慮 API 使用者的安全性、性能和易用性。
否則,我們會(huì)為使用我們 API 的客戶制造問題,這不愉快并且會(huì)影響人們使用我們的 API。如果我們不遵循普遍接受的約定,那么我們就會(huì)混淆 API 的維護(hù)者和使用它們的客戶端,因?yàn)樗c每個(gè)人的期望不同。
在本文中,我們將研究如何設(shè)計(jì) REST API,使其對(duì)任何使用它們的人都易于理解、面向未來、安全且快速,因?yàn)樗鼈兿蚩赡苁菣C(jī)密的客戶端提供數(shù)據(jù)。
- 接受并使用 JSON 響應(yīng)
- 在端點(diǎn)路徑中使用名詞而不是動(dòng)詞
- 用復(fù)數(shù)名詞命名集合
- 分層對(duì)象的嵌套資源
- 優(yōu)雅地處理錯(cuò)誤并返回標(biāo)準(zhǔn)錯(cuò)誤代碼
- 允許過濾、排序和分頁
- 保持良好的安全實(shí)踐
- 緩存數(shù)據(jù)以提高性能
- 版本控制我們的 API
什么是 REST API?
REST API 是符合特定架構(gòu)約束的應(yīng)用程序編程接口,例如無狀態(tài)通信和可緩存數(shù)據(jù)。它不是協(xié)議或標(biāo)準(zhǔn)。雖然可以通過多種通信協(xié)議訪問 REST API,但最常見的是通過 HTTPS 調(diào)用它們,因此以下指南適用于將通過 Internet 調(diào)用的 REST API 端點(diǎn)。
注意:對(duì)于通過 Internet 調(diào)用的 REST API,您需要遵循REST API 身份驗(yàn)證的最佳實(shí)踐。
接受并使用 JSON 響應(yīng)
REST API 應(yīng)該接受 JSON 作為請(qǐng)求負(fù)載,并發(fā)送響應(yīng)到 JSON。JSON 是傳輸數(shù)據(jù)的標(biāo)準(zhǔn)。幾乎所有聯(lián)網(wǎng)技術(shù)都可以使用它:JAVAScript 具有通過 Fetch API 或其他 HTTP 客戶端對(duì) JSON 進(jìn)行編碼和解碼的內(nèi)置方法。服務(wù)器端技術(shù)具有無需做太多工作即可解碼 JSON 的庫。
還有其他方法可以傳輸數(shù)據(jù)。如果不將數(shù)據(jù)自己轉(zhuǎn)換為可以使用的東西(通常是 JSON),框架就不會(huì)廣泛支持 XML。我們無法在客戶端輕松操作這些數(shù)據(jù),尤其是在瀏覽器中。只是為了進(jìn)行正常的數(shù)據(jù)傳輸,最終需要做很多額外的工作。
表單數(shù)據(jù)有利于發(fā)送數(shù)據(jù),特別是如果我們要發(fā)送文件。但是對(duì)于文本和數(shù)字,我們不需要表單數(shù)據(jù)來傳輸它們,因?yàn)閷?duì)于大多數(shù)框架,我們可以通過直接在客戶端獲取數(shù)據(jù)來傳輸 JSON。這是迄今為止最直接的做法。
為確保當(dāng)我們的 REST API 應(yīng)用程序使用 JSON 響應(yīng)時(shí)客戶端將其解釋為這樣,我們應(yīng)該Content-Type在響應(yīng)標(biāo)頭中設(shè)置為Application/json在發(fā)出請(qǐng)求之后。許多服務(wù)器端應(yīng)用程序框架會(huì)自動(dòng)設(shè)置響應(yīng)標(biāo)頭。一些 HTTP 客戶端查看Content-Type響應(yīng)標(biāo)頭并根據(jù)該格式解析數(shù)據(jù)。
唯一的例外是如果我們嘗試在客戶端和服務(wù)器之間發(fā)送和接收文件。然后我們需要處理文件響應(yīng)并將表單數(shù)據(jù)從客戶端發(fā)送到服務(wù)器。但這是另一個(gè)話題。
我們還應(yīng)該確保我們的端點(diǎn)返回 JSON 作為響應(yīng)。許多服務(wù)器端框架將此作為內(nèi)置功能。
讓我們看一個(gè)接受 JSON 有效負(fù)載的示例 API。此示例將使用Node.js的Express后端框架。我們可以使用body-parser中間件來解析 JSON 請(qǐng)求體,然后我們可以res.json使用我們想要作為 JSON 響應(yīng)返回的對(duì)象調(diào)用該方法,如下所示:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.post('/', (req, res) => {
res.json(req.body);
});
app.listen(3000, () => console.log('server started'));
bodyParser.json()將 JSON 請(qǐng)求正文字符串解析為 JavaScript 對(duì)象,然后將其分配給該req.body對(duì)象。
將Content-Type響應(yīng)中的標(biāo)頭設(shè)置為application/json; charset=utf-8不做任何更改。上述方法適用于大多數(shù)其他后端框架。
在端點(diǎn)路徑中使用名詞而不是動(dòng)詞
我們不應(yīng)該在端點(diǎn)路徑中使用動(dòng)詞。相反,我們應(yīng)該使用代表我們正在檢索或操作的端點(diǎn)的實(shí)體的名詞作為路徑名。
這是因?yàn)槲覀兊?HTTP 請(qǐng)求方法已經(jīng)有了動(dòng)詞。在我們的 API 端點(diǎn)路徑中包含動(dòng)詞沒有用處,而且由于它沒有傳達(dá)任何新信息,它會(huì)導(dǎo)致不必要的冗長。選擇的動(dòng)詞可能會(huì)因開發(fā)人員的突發(fā)奇想而有所不同。例如,有些喜歡“get”,有些喜歡“retrieve”,所以最好讓 HTTP GET 動(dòng)詞告訴我們端點(diǎn)做什么。
該操作應(yīng)由我們正在制作的 HTTP 請(qǐng)求方法指示。最常見的方法包括 GET、POST、PUT 和 DELETE。
- GET 檢索資源。
- POST 向服務(wù)器提交新數(shù)據(jù)。
- PUT 更新現(xiàn)有數(shù)據(jù)。
- DELETE 刪除數(shù)據(jù)。
動(dòng)詞映射到CRUD操作。
考慮到我們上面討論的兩個(gè)原則,我們應(yīng)該創(chuàng)建像 GET 這樣的路由/articles/來獲取新聞文章。同樣,POST/articles/用于添加新文章,PUT/articles/:id用于使用給定的更新文章id。DELETE/articles/:id用于刪除具有給定 ID 的現(xiàn)有文章。
/articles表示 REST API 資源。例如,我們可以使用 Express 添加以下端點(diǎn)來操作文章,如下所示:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.get('/articles', (req, res) => {
const articles = [];
// code to retrieve an article...
res.json(articles);
});
app.post('/articles', (req, res) => {
// code to add a new article...
res.json(req.body);
});
app.put('/articles/:id', (req, res) => {
const { id } = req.params;
// code to update an article...
res.json(req.body);
});
app.delete('/articles/:id', (req, res) => {
const { id } = req.params;
// code to delete an article...
res.json({ deleted: id });
});
app.listen(3000, () => console.log('server started'));
在上面的代碼中,我們定義了操作文章的端點(diǎn)。正如我們所見,路徑名中沒有任何動(dòng)詞。我們只有名詞。動(dòng)詞在 HTTP 動(dòng)詞中。
POST、PUT 和 DELETE 端點(diǎn)都將 JSON 作為請(qǐng)求體,它們都返回 JSON 作為響應(yīng),包括 GET 端點(diǎn)。
在端點(diǎn)上使用邏輯嵌套
在設(shè)計(jì)端點(diǎn)時(shí),將包含相關(guān)信息的端點(diǎn)分組是有意義的。也就是說,如果一個(gè)對(duì)象可以包含另一個(gè)對(duì)象,您應(yīng)該設(shè)計(jì)端點(diǎn)來反映這一點(diǎn)。無論您的數(shù)據(jù)在數(shù)據(jù)庫中的結(jié)構(gòu)是否如此,這都是一種很好的做法。事實(shí)上,避免在端點(diǎn)中鏡像數(shù)據(jù)庫結(jié)構(gòu)以避免給攻擊者提供不必要的信息可能是明智的。
例如,如果我們希望端點(diǎn)獲取新聞文章的評(píng)論,我們應(yīng)該將/comments路徑附加到路徑的末尾/articles。我們可以在 Express 中使用以下代碼來做到這一點(diǎn):
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.get('/articles/:articleId/comments', (req, res) => {
const { articleId } = req.params;
const comments = [];
// code to get comments by articleId
res.json(comments);
});
app.listen(3000, () => console.log('server started'));
在上面的代碼中,我們可以在 path 上使用 GET 方法'
/articles/:articleId/comments'。我們獲取comments由標(biāo)識(shí)的文章articleId,然后在響應(yīng)中返回它。我們'comments'在'/articles/:articleId'路徑段之后添加以表明它是 的子資源/articles。
這是有道理的,因?yàn)閏omments是 的子對(duì)象articles,假設(shè)每篇文章都有自己的評(píng)論。否則,用戶會(huì)感到困惑,因?yàn)檫@種結(jié)構(gòu)通常被認(rèn)為是用于訪問子對(duì)象。同樣的原則也適用于 POST、PUT 和 DELETE 端點(diǎn)。它們都可以對(duì)路徑名使用相同類型的嵌套結(jié)構(gòu)。
但是,嵌套可能會(huì)走得太遠(yuǎn)。在大約第二或第三級(jí)之后,嵌套端點(diǎn)可能會(huì)變得笨拙。相反,請(qǐng)考慮將 URL 返回到這些資源,特別是如果該數(shù)據(jù)不一定包含在頂級(jí)對(duì)象中。
例如,假設(shè)您想返回特定評(píng)論的作者。你可以使用
/articles/:articleId/comments/:commentId/author. 但這已經(jīng)失控了。而是在 JSON 響應(yīng)中返回該特定用戶的 URI:
"author": "/users/:userId"
優(yōu)雅地處理錯(cuò)誤并返回標(biāo)準(zhǔn)錯(cuò)誤代碼
為了在錯(cuò)誤發(fā)生時(shí)消除 API 用戶的困惑,我們應(yīng)該優(yōu)雅地處理錯(cuò)誤并返回指示發(fā)生了哪種錯(cuò)誤的 HTTP 響應(yīng)代碼。這為 API 的維護(hù)者提供了足夠的信息來了解發(fā)生的問題。我們不希望錯(cuò)誤導(dǎo)致我們的系統(tǒng)崩潰,所以我們可以不處理它們,這意味著 API 使用者必須處理它們。
常見的錯(cuò)誤 HTTP 狀態(tài)代碼包括:
- 400 Bad Request – 這意味著客戶端輸入驗(yàn)證失敗。
- 401 Unauthorized – 這意味著用戶無權(quán)訪問資源。它通常在用戶未通過身份驗(yàn)證時(shí)返回。
- 403 Forbidden - 這意味著用戶已通過身份驗(yàn)證,但不允許訪問資源。
- 404 Not Found – 這表示未找到資源。
- 500 內(nèi)部服務(wù)器錯(cuò)誤 – 這是一般的服務(wù)器錯(cuò)誤。它可能不應(yīng)該明確拋出。
- 502 Bad Gateway - 這表示來自上游服務(wù)器的無效響應(yīng)。
- 503 Service Unavailable - 這表明服務(wù)器端發(fā)生了意外(可能是服務(wù)器過載,系統(tǒng)某些部分發(fā)生故障等)。
我們應(yīng)該拋出與我們的應(yīng)用程序遇到的問題相對(duì)應(yīng)的錯(cuò)誤。例如,如果我們想拒絕來自請(qǐng)求負(fù)載的數(shù)據(jù),那么我們應(yīng)該在 Express API 中返回一個(gè) 400 響應(yīng),如下所示:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// existing users
const users = [
{ email: '[email protected]' }
]
app.use(bodyParser.json());
app.post('/users', (req, res) => {
const { email } = req.body;
const userExists = users.find(u => u.email === email);
if (userExists) {
return res.status(400).json({ error: 'User already exists' })
}
res.json(req.body);
});
app.listen(3000, () => console.log('server started'));
在上面的代碼中,我們users在給定電子郵件的數(shù)組中有一個(gè)現(xiàn)有用戶的列表。
然后,如果我們嘗試使用email中已存在的值提交有效負(fù)載users,我們將獲得一個(gè) 400 響應(yīng)狀態(tài)碼和一條'User already exists'消息,讓用戶知道該用戶已經(jīng)存在。使用該信息,用戶可以通過將電子郵件更改為不存在的內(nèi)容來更正操作。
錯(cuò)誤代碼需要帶有消息,以便維護(hù)者有足夠的信息來解決問題,但攻擊者不能使用錯(cuò)誤內(nèi)容來進(jìn)行我們的攻擊,如竊取信息或關(guān)閉系統(tǒng)。
每當(dāng)我們的 API 沒有成功完成時(shí),我們應(yīng)該通過發(fā)送錯(cuò)誤信息來幫助用戶做出糾正措施,從而優(yōu)雅地失敗。
允許過濾、排序和分頁
REST API 背后的數(shù)據(jù)庫可能會(huì)變得非常大。有時(shí),有太多的數(shù)據(jù)不應(yīng)該一次全部返回,因?yàn)樗蛘邥?huì)導(dǎo)致我們的系統(tǒng)崩潰。因此,我們需要過濾項(xiàng)目的方法。
我們還需要對(duì)數(shù)據(jù)進(jìn)行分頁的方法,以便一次只返回幾個(gè)結(jié)果。我們不想通過嘗試一次獲取所有請(qǐng)求的數(shù)據(jù)來占用資源太久。
過濾和分頁都通過減少服務(wù)器資源的使用來提高性能。隨著數(shù)據(jù)庫中積累的數(shù)據(jù)越多,這些特征就越重要。
這是一個(gè)小示例,其中 API 可以接受具有各種查詢參數(shù)的查詢字符串,以便我們按字段過濾項(xiàng)目:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// employees data in a database
const employees = [
{ firstName: 'Jane', lastName: 'Smith', age: 20 },
//...
{ firstName: 'John', lastName: 'Smith', age: 30 },
{ firstName: 'Mary', lastName: 'Green', age: 50 },
]
app.use(bodyParser.json());
app.get('/employees', (req, res) => {
const { firstName, lastName, age } = req.query;
let results = [...employees];
if (firstName) {
results = results.filter(r => r.firstName === firstName);
}
if (lastName) {
results = results.filter(r => r.lastName === lastName);
}
if (age) {
results = results.filter(r => +r.age === +age);
}
res.json(results);
});
app.listen(3000, () => console.log('server started'));
在上面的代碼中,我們有req.query獲取查詢參數(shù)的變量。然后,我們通過使用 JavaScript 解構(gòu)語法將各個(gè)查詢參數(shù)解構(gòu)為變量來提取屬性值。最后,我們filter繼續(xù)使用每個(gè)查詢參數(shù)值來定位我們想要返回的項(xiàng)目。
完成此操作后,我們將返回results作為響應(yīng)。因此,當(dāng)我們使用查詢字符串向以下路徑發(fā)出 GET 請(qǐng)求時(shí):
/employees?lastName=Smith&age=30
我們得到:
[
{
"firstName": "John",
"lastName": "Smith",
"age": 30
}
]
作為我們過濾后的返回響應(yīng)lastName和age。
同樣,我們可以接受page查詢參數(shù)并返回位置 from (page - 1) * 20to中的一組條目page * 20。
我們還可以在查詢字符串中指定要排序的字段。例如,我們可以從帶有我們想要對(duì)其數(shù)據(jù)進(jìn)行排序的字段的查詢字符串中獲取參數(shù)。然后我們可以按這些單獨(dú)的字段對(duì)它們進(jìn)行排序。
例如,我們可能想從如下 URL 中提取查詢字符串:
http://example.com/articles?sort=+author,-datepublished
where+表示上升,-表示下降。所以我們按照作者姓名的字母順序,datepublished從最近到最近的排序。
保持良好的安全實(shí)踐
客戶端和服務(wù)器之間的大多數(shù)通信應(yīng)該是私密的,因?yàn)槲覀兘?jīng)常發(fā)送和接收私人信息。因此,必須使用 SSL/TLS 來確保安全。
將 SSL 證書加載到服務(wù)器上并不難,而且成本免費(fèi)或非常低。沒有理由不讓我們的 REST API 通過安全通道而不是公開方式進(jìn)行通信。
人們不應(yīng)該能夠訪問他們請(qǐng)求的更多信息。例如,普通用戶不應(yīng)該能夠訪問其他用戶的信息。他們也不應(yīng)該能夠訪問管理員的數(shù)據(jù)。
為了執(zhí)行最小權(quán)限原則,我們需要為單個(gè)角色添加角色檢查,或者為每個(gè)用戶添加更細(xì)化的角色。
如果我們選擇將用戶分組為幾個(gè)角色,那么這些角色應(yīng)該具有涵蓋他們需要的所有權(quán)限,僅此而已。如果我們對(duì)用戶可以訪問的每個(gè)功能都擁有更精細(xì)的權(quán)限,那么我們必須確保管理員可以相應(yīng)地從每個(gè)用戶添加和刪除這些功能。此外,我們需要添加一些可以應(yīng)用于組用戶的預(yù)設(shè)角色,這樣我們就不必手動(dòng)為每個(gè)用戶執(zhí)行此操作。
緩存數(shù)據(jù)以提高性能
我們可以添加緩存以從本地內(nèi)存緩存返回?cái)?shù)據(jù),而不是每次想要檢索用戶請(qǐng)求的一些數(shù)據(jù)時(shí)都查詢數(shù)據(jù)庫來獲取數(shù)據(jù)。緩存的好處是用戶可以更快地獲取數(shù)據(jù)。但是,用戶獲得的數(shù)據(jù)可能已經(jīng)過時(shí)。當(dāng)我們不斷看到舊數(shù)據(jù)時(shí)出現(xiàn)問題時(shí),這也可能導(dǎo)致在生產(chǎn)環(huán)境中調(diào)試時(shí)出現(xiàn)問題。
緩存解決方案有很多種,比如redis、內(nèi)存緩存等等。我們可以隨著需求的變化而改變緩存數(shù)據(jù)的方式。
例如,Express 具有apicache無需太多配置即可將緩存添加到我們的應(yīng)用程序的中間件。我們可以像這樣在我們的服務(wù)器中添加一個(gè)簡單的內(nèi)存緩存:
const express = require('express');
const bodyParser = require('body-parser');
const apicache = require('apicache');
const app = express();
let cache = apicache.middleware;
app.use(cache('5 minutes'));
// employees data in a database
const employees = [
{ firstName: 'Jane', lastName: 'Smith', age: 20 },
//...
{ firstName: 'John', lastName: 'Smith', age: 30 },
{ firstName: 'Mary', lastName: 'Green', age: 50 },
]
app.use(bodyParser.json());
app.get('/employees', (req, res) => {
res.json(employees);
});
app.listen(3000, () => console.log('server started'));
上面的代碼只是引用了apicache中間件,apicache.middleware然后我們有:
app.use(cache('5 minutes'))
將緩存應(yīng)用于整個(gè)應(yīng)用程序。例如,我們將結(jié)果緩存五分鐘。我們可以根據(jù)需要進(jìn)行調(diào)整。
如果您使用緩存,您還應(yīng)該Cache-Control在標(biāo)題中包含信息。這將幫助用戶有效地使用您的緩存系統(tǒng)。
版本控制我們的 API
如果我們對(duì)它們進(jìn)行任何可能破壞客戶端的更改,我們應(yīng)該有不同版本的 API。可以像現(xiàn)在大多數(shù)應(yīng)用程序一樣,根據(jù)語義版本(例如,2.0.6 表示主要版本 2 和第六個(gè)補(bǔ)丁)進(jìn)行版本控制。
這樣,我們可以逐步淘汰舊的端點(diǎn),而不是強(qiáng)迫每個(gè)人同時(shí)遷移到新的 API。v1 端點(diǎn)可以為不想改變的人保持活躍,而 v2 具有閃亮的新功能,可以為那些準(zhǔn)備升級(jí)的人提供服務(wù)。如果我們的 API 是公開的,這一點(diǎn)尤其重要。我們應(yīng)該對(duì)它們進(jìn)行版本控制,這樣我們就不會(huì)破壞使用我們 API 的第三方應(yīng)用程序。
版本通常有做/v1/,/v2/等在API路徑的開始增加。
例如,我們可以使用 Express 執(zhí)行此操作,如下所示:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.get('/v1/employees', (req, res) => {
const employees = [];
// code to get employees
res.json(employees);
});
app.get('/v2/employees', (req, res) => {
const employees = [];
// different code to get employees
res.json(employees);
});
app.listen(3000, () => console.log('server started'));
我們只需將版本號(hào)添加到端點(diǎn) URL 路徑的開頭即可對(duì)其進(jìn)行版本控制。
結(jié)論
設(shè)計(jì)高質(zhì)量 REST API 最重要的一點(diǎn)是通過遵循 Web 標(biāo)準(zhǔn)和約定來保持一致性。JSON、SSL/TLS 和 HTTP 狀態(tài)代碼都是現(xiàn)代 Web 的標(biāo)準(zhǔn)構(gòu)建塊。
性能也是一個(gè)重要的考慮因素。我們可以通過不一次返回太多數(shù)據(jù)來增加它。此外,我們可以使用緩存,這樣我們就不必一直查詢數(shù)據(jù)。
端點(diǎn)的路徑應(yīng)該是一致的,我們只使用名詞,因?yàn)?HTTP 方法表明我們想要采取的行動(dòng)。嵌套資源的路徑應(yīng)該在父資源的路徑之后。他們應(yīng)該告訴我們我們正在獲取或操作什么,而無需閱讀額外的文檔來了解它在做什么。