Web API
已經在最近幾年變成重要的話題,一個乾淨的 API 設計對於後端系統是很是重要的。html
一般咱們爲 Web API 使用 RESTful
設計,REST
概念分離了 API 結構
和邏輯資源
,經過 Http 方法GET
, DELETE
, POST
和 PUT
等 來操做資源。前端
本篇文章是結合我最近的一個項目,基於koa+mongodb+jwt
來給你們講述一下 RESTful API 的最佳實踐。node
具體瞭解RESTful API
前,讓咱們先來看一下什麼是REST
。jquery
REST
的全稱是Representational state transfer
。具體以下:git
它描述了一個系統如何與另外一個交流。好比一個產品的狀態(名字,詳情)表現爲 XML,JSON 或者普通文本。github
REST 有六個約束:web
關注點分離。服務端專一數據存儲,提高了簡單性,前端專一用戶界面,提高了可移植性。mongodb
全部用戶會話信息都保存在客戶端。每次請求必須包括全部信息,不能依賴上下文信息。服務端不用保存會話信息,提高了簡單性、可靠性、可見性。數據庫
全部服務端響應都要被標爲可緩存或不可緩存,減小先後端交互,提高了性能。npm
接口設計儘量統一通用,提高了簡單性、可見性。接口與實現解耦,使先後端能夠獨立開發迭代。
看完了 REST 的六個約束,下面讓咱們來看一下行業內對於RESTful API
設計最佳實踐的總結。
請求設計規範
URI 使用名詞
,儘可能使用複數,如/users嵌套
表示關聯關係
,如/users/123/repos/234正確的 HTTP 方法
,如 GET/POST/PUT/DELETE響應設計規範
查詢
分頁
字段過濾
若是記錄數量不少,服務器不可能都將它們返回給用戶。API 應該提供參數,過濾返回結果。下面是一些常見的參數(包括上面的查詢、分頁以及字段過濾):
?limit=10:指定返回記錄的數量 ?offset=10:指定返回記錄的開始位置。 ?page=2&per_page=100:指定第幾頁,以及每頁的記錄數。 ?sortby=name&order=asc:指定返回結果按照哪一個屬性排序,以及排序順序。 ?animal_type_id=1:指定篩選條件
狀態碼
錯誤處理
就像 HTML 的出錯頁面向訪問者展現了有用的錯誤消息同樣,API 也應該用以前清晰易讀的格式來提供有用的錯誤消息。
好比對於常見的提交表單,當遇到以下錯誤信息時:
{ "error": "Invalid payoad.", "detail": { "surname": "This field is required." } }
接口調用者很快就能定位到錯誤緣由。
安全
HTTPS
鑑權
RESTful API 應該是無狀態。這意味着對請求的認證不該該基於cookie
或者session
。相反,每一個請求應該帶有一些認證憑證。
限流
爲了不請求氾濫,給 API 設置速度限制
很重要。爲此 RFC 6585
引入了 HTTP 狀態碼429(too many requests)
。加入速度設置以後,應該給予用戶提示。
上面說了這麼多,下面讓咱們看一下如何在 Koa 中踐行RESTful API最佳實踐
吧。
先來看一下完成後的項目目錄結構:
|-- rest_node_api |-- .gitignore |-- README.md |-- package-lock.json |-- package.json # 項目依賴 |-- app |-- config.js # 數據庫(mongodb)配置信息 |-- index.js # 入口 |-- controllers # 控制器:用於解析用戶輸入,處理後返回相應的結果 |-- models # 模型(schema): 用於定義數據模型 |-- public # 靜態資源 |-- routes # 路由
項目的目錄呈現了清晰的分層、分模塊結構,也便於後期的維護和擴展。下面咱們會對項目中須要注意的幾點一一說明。
什麼是控制器?
爲何要用控制器
獲取 HTTP 請求參數
處理業務邏輯
發送 HTTP 響應
編寫控制器的最佳實踐
示例
app/controllers/users.js
const User = require("../models/users"); class UserController { async create(ctx) { ctx.verifyParams({ name: { type: "string", required: true }, password: { type: "string", required: true } }); const { name } = ctx.request.body; const repeatedUser = await User.findOne({ name }); if (repeatedUser) { ctx.throw(409, "用戶名已存在"); } const user = await new User(ctx.request.body).save(); ctx.body = user; } } module.exports = new UserController();
koa自帶錯誤處理
要執行自定義錯誤處理邏輯,如集中式日誌記錄,您能夠添加一個 「error」 事件偵聽器:
app.on('error', err => { log.error('server error', err) });
中間件
本項目中採用koa-json-error
來處理錯誤,關於該中間件的詳細介紹會在下文展開。
目前經常使用的用於用戶信息認證與受權的有兩種方式-JWT
和Session
。下面咱們分別對比一下兩種鑑權方式的優劣點。
Session
相關的概念介紹
session
::主要存放在服務器,相對安全cookie
:主要存放在客戶端,而且不是很安全sessionStorage
:僅在當前會話下有效,關閉頁面或瀏覽器後被清除localstorage
:除非被清除,不然永久保存工做原理
優點
劣勢
JWT
相關的概念介紹
因爲詳細的介紹 JWT 會佔用大量文章篇幅,也不是本文的重點。因此這裏只是簡單介紹一下。主要是和 Session 方式作一個對比。關於 JWT 詳細的介紹能夠參考
https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
JWT 的原理是,服務器認證之後,生成一個 JSON 對象,發回給用戶,就像下面這樣:
{ "姓名": "森林", "角色": "搬磚工", "到期時間": "2020年1月198日16點32分" }
之後,用戶與服務端通訊的時候,都要發回這個 JSON 對象。服務器徹底只靠這個對象認證用戶身份。爲了防止用戶篡改數據,服務器在生成這個對象的時候,會加上簽名。
服務器就不保存任何 session 數據了,也就是說,服務器變成無狀態了,從而比較容易實現擴展。
JWT 的格式大體以下:
它是一個很長的字符串,中間用點(.)分隔成三個部分。
JWT 的三個部分依次以下:
Header(頭部) Payload(負載) Signature(簽名)
JWT相比Session
jsonwebtoken
因爲 RESTful API 提倡無狀態,而 JWT 又恰巧符合這一要求,所以咱們採用JWT
來實現用戶信息的受權與認證。
項目中採用的是比較流行的jsonwebtoken
。具體使用方式能夠參考https://www.npmjs.com/package/jsonwebtoken
mkdir rest_node_api # 建立文件目錄 cd rest_node_api # 定位到當前文件目錄 npm init # 初始化,獲得`package.json`文件 npm i koa -S # 安裝koa npm i koa-router -S # 安裝koa-router
基礎依賴安裝好後能夠先搞一個hello-world
app/index.js
const Koa = require("koa"); const Router = require("koa-router"); const app = new Koa(); const router = new Router(); router.get("/", async function (ctx) { ctx.body = {message: "Hello World!"} }); app.use(router.routes()).use(router.allowedMethods()); app.listen(3000);
koa-body
以前使用 koa2
的時候,處理 post
請求使用的是 koa-bodyparser
,同時若是是圖片上傳使用的是 koa-multer
。這二者的組合沒什麼問題,不過 koa-multer 和 koa-route(注意不是 koa-router) 存在不兼容的問題。
koa-body
結合了兩者,因此 koa-body 能夠對其進行代替。
依賴安裝
npm i koa-body -S
app/index.js
const koaBody = require('koa-body'); const app = new koa(); app.use(koaBody({ multipart:true, // 支持文件上傳 encoding:'gzip', formidable:{ uploadDir:path.join(__dirname,'public/uploads'), // 設置文件上傳目錄 keepExtensions: true, // 保持文件的後綴 maxFieldsSize:2 * 1024 * 1024, // 文件上傳大小 onFileBegin:(name,file) => { // 文件上傳前的設置 // console.log(`name: ${name}`); // console.log(file); }, } }));
參數配置:
基本參數
參數名 | 描述 | 類型 | 默認值 |
---|---|---|---|
patchNode | 將請求體打到原生 node.js 的ctx.req 中 |
Boolean | false |
patchKoa | 將請求體打到 koa 的 ctx.request 中 |
Boolean | true |
jsonLimit | JSON 數據體的大小限制 | String / Integer | 1mb |
formLimit | 限制表單請求體的大小 | String / Integer | 24kb |
textLimit | 限制 text body 的大小 | String / Integer | 23kb |
encoding | 表單的默認編碼 | String | utf-8 |
multipart | 是否支持 multipart-formdate 的表單 |
Boolean | false |
urlencoded | 是否支持 urlencoded 的表單 |
Boolean | true |
formidable | 配置更多的關於 multipart 的選項 |
Object | {} |
onError | 錯誤處理 | Function | function(){} |
stict | 嚴格模式,啓用後不會解析 GET, HEAD, DELETE 請求 |
Boolean | true |
formidable 的相關配置參數
參數名 | 描述 | 類型 | 默認值 |
---|---|---|---|
maxFields | 限制字段的數量 | Integer | 500 |
maxFieldsSize | 限制字段的最大大小 | Integer | 1 * 1024 * 1024 |
uploadDir | 文件上傳的文件夾 | String | os.tmpDir() |
keepExtensions | 保留原來的文件後綴 | Boolean | false |
hash | 若是要計算文件的 hash,則能夠選擇 md5/sha1 |
String | false |
multipart | 是否支持多文件上傳 | Boolean | true |
onFileBegin | 文件上傳前的一些設置操做 | Function | function(name,file){} |
koa-json-error
在寫接口時,返回json
格式且易讀的錯誤提示是有必要的,koa-json-error
中間件幫咱們作到了這一點。
依賴安裝
npm i koa-json-error -S
app/index.js
const error = require("koa-json-error"); const app = new Koa(); app.use( error({ postFormat: (e, { stack, ...rest }) => process.env.NODE_ENV === "production" ? rest : { stack, ...rest } }) );
錯誤會默認拋出堆棧信息stack
,在生產環境中,不必返回給用戶,在開發環境顯示便可。
koa-parameter
採用koa-parameter
用於參數校驗,它是基於參數驗證框架parameter
, 給 koa 框架作的適配。
依賴安裝
npm i koa-parameter -S
使用
// app/index.js const parameter = require("koa-parameter"); app.use(parameter(app)); // app/controllers/users.js async create(ctx) { ctx.verifyParams({ name: { type: "string", required: true }, password: { type: "string", required: true } }); ... }
由於koa-parameter
是基於parameter的
,只是作了一層封裝而已,底層邏輯仍是按照 parameter 來的,自定義規則徹底能夠參照 parameter 官方說明和示例來編寫。
let TYPE_MAP = Parameter.TYPE_MAP = { number: checkNumber, int: checkInt, integer: checkInt, string: checkString, id: checkId, date: checkDate, dateTime: checkDateTime, datetime: checkDateTime, boolean: checkBoolean, bool: checkBoolean, array: checkArray, object: checkObject, enum: checkEnum, email: checkEmail, password: checkPassword, url: checkUrl, };
koa-static
若是網站提供靜態資源(圖片、字體、樣式、腳本......),爲它們一個個寫路由就很麻煩,也不必。koa-static
模塊封裝了這部分的請求。
app/index.js
const Koa = require("koa"); const koaStatic = require("koa-static"); const app = new Koa(); app.use(koaStatic(path.join(__dirname, "public")));
數據庫咱們採用的是mongodb
,鏈接數據庫前,咱們要先來看一下mongoose
。
mongoose
是nodeJS
提供鏈接 mongodb
的一個庫,相似於jquery
和js
的關係,對mongodb
一些原生方法進行了封裝以及優化。簡單的說,Mongoose
就是對node
環境中MongoDB
數據庫操做的封裝,一個對象模型(ODM
)工具,將數據庫中的數據轉換爲JavaScript
對象以供咱們在應用中使用。
安裝 mongoose
npm install mongoose -S
鏈接及配置
const mongoose = require("mongoose"); mongoose.connect( connectionStr, // 數據庫地址 { useUnifiedTopology: true, useNewUrlParser: true }, () => console.log("mongodb 鏈接成功了!") ); mongoose.connection.on("error", console.error);
項目中的模塊是比較多的,我不會一一去演示,由於各個模塊實質性的內容是大同小異的。在這裏主要是以用戶模塊的crud
爲例來展現下如何在 koa 中踐行RESTful API最佳實踐
。
app/index.js
(koa 入口)入口文件主要用於建立 koa 服務、裝載 middleware(中間件)、路由註冊(交由 routes 模塊處理)、鏈接數據庫等。
const Koa = require("koa"); const path = require("path"); const koaBody = require("koa-body"); const koaStatic = require("koa-static"); const parameter = require("koa-parameter"); const error = require("koa-json-error"); const mongoose = require("mongoose"); const routing = require("./routes"); const app = new Koa(); const { connectionStr } = require("./config"); mongoose.connect( // 鏈接mongodb connectionStr, { useUnifiedTopology: true, useNewUrlParser: true }, () => console.log("mongodb 鏈接成功了!") ); mongoose.connection.on("error", console.error); app.use(koaStatic(path.join(__dirname, "public"))); // 靜態資源 app.use( // 錯誤處理 error({ postFormat: (e, { stack, ...rest }) => process.env.NODE_ENV === "production" ? rest : { stack, ...rest } }) ); app.use( // 處理post請求和圖片上傳 koaBody({ multipart: true, formidable: { uploadDir: path.join(__dirname, "/public/uploads"), keepExtensions: true } }) ); app.use(parameter(app)); // 參數校驗 routing(app); // 路由處理 app.listen(3000, () => console.log("程序啓動在3000端口了"));
app/routes/index.js
因爲項目模塊較多,對應的路由也不少。若是一個個的去註冊,有點太麻煩了。這裏用 node 的 fs 模塊去遍歷讀取 routes 下的全部路由文件,統一註冊。
const fs = require("fs"); module.exports = app => { fs.readdirSync(__dirname).forEach(file => { if (file === "index.js") { return; } const route = require(`./${file}`); app.use(route.routes()).use(route.allowedMethods()); }); };
app/routes/users.js
用戶模塊路由,裏面主要涉及到了用戶的登陸以及增刪改查。
const jsonwebtoken = require("jsonwebtoken"); const jwt = require("koa-jwt"); const { secret } = require("../config"); const Router = require("koa-router"); const router = new Router({ prefix: "/users" }); // 路由前綴 const { find, findById, create, checkOwner, update, delete: del, login, } = require("../controllers/users"); // 控制器方法 const auth = jwt({ secret }); // jwt鑑權 router.get("/", find); // 獲取用戶列表 router.post("/", auth, create); // 建立用戶(須要jwt認證) router.get("/:id", findById); // 獲取特定用戶 router.patch("/:id", auth, checkOwner, update); // 更新用戶信息(須要jwt認證和驗證操做用戶身份) router.delete("/:id", auth, checkOwner, del); // 刪除用戶(須要jwt認證和驗證操做用戶身份) router.post("/login", login); // 用戶登陸 module.exports = router;
app/models/users.js
用戶數據模型(schema)
const mongoose = require("mongoose"); const { Schema, model } = mongoose; const userSchema = new Schema( { __v: { type: Number, select: false }, name: { type: String, required: true }, // 用戶名 password: { type: String, required: true, select: false }, // 密碼 avatar_url: { type: String }, // 頭像 gender: { // 性別 type: String, enum: ["male", "female"], default: "male", required: true }, headline: { type: String }, // 座右銘 locations: { // 居住地 type: [{ type: Schema.Types.ObjectId, ref: "Topic" }], select: false }, business: { type: Schema.Types.ObjectId, ref: "Topic", select: false }, // 職業 }, { timestamps: true } ); module.exports = model("User", userSchema);
app/controllers/users.js
用戶模塊控制器,用於處理業務邏輯
const User = require("../models/users"); const jsonwebtoken = require("jsonwebtoken"); const { secret } = require("../config"); class UserController { async find(ctx) { // 查詢用戶列表(分頁) const { per_page = 10 } = ctx.query; const page = Math.max(ctx.query.page * 1, 1) - 1; const perPage = Math.max(per_page * 1, 1); ctx.body = await User.find({ name: new RegExp(ctx.query.q) }) .limit(perPage) .skip(page * perPage); } async findById(ctx) { // 根據id查詢特定用戶 const { fields } = ctx.query; const selectFields = // 查詢條件 fields && fields .split(";") .filter(f => f) .map(f => " +" + f) .join(""); const populateStr = // 展現字段 fields && fields .split(";") .filter(f => f) .map(f => { if (f === "employments") { return "employments.company employments.job"; } if (f === "educations") { return "educations.school educations.major"; } return f; }) .join(" "); const user = await User.findById(ctx.params.id) .select(selectFields) .populate(populateStr); if (!user) { ctx.throw(404, "用戶不存在"); } ctx.body = user; } async create(ctx) { // 建立用戶 ctx.verifyParams({ // 入參格式校驗 name: { type: "string", required: true }, password: { type: "string", required: true } }); const { name } = ctx.request.body; const repeatedUser = await User.findOne({ name }); if (repeatedUser) { // 校驗用戶名是否已存在 ctx.throw(409, "用戶名已存在"); } const user = await new User(ctx.request.body).save(); ctx.body = user; } async checkOwner(ctx, next) { // 判斷用戶身份合法性 if (ctx.params.id !== ctx.state.user._id) { ctx.throw(403, "沒有權限"); } await next(); } async update(ctx) { // 更新用戶信息 ctx.verifyParams({ name: { type: "string", required: false }, password: { type: "string", required: false }, avatar_url: { type: "string", required: false }, gender: { type: "string", required: false }, headline: { type: "string", required: false }, locations: { type: "array", itemType: "string", required: false }, business: { type: "string", required: false }, }); const user = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body); if (!user) { ctx.throw(404, "用戶不存在"); } ctx.body = user; } async delete(ctx) { // 刪除用戶 const user = await User.findByIdAndRemove(ctx.params.id); if (!user) { ctx.throw(404, "用戶不存在"); } ctx.status = 204; } async login(ctx) { // 登陸 ctx.verifyParams({ name: { type: "string", required: true }, password: { type: "string", required: true } }); const user = await User.findOne(ctx.request.body); if (!user) { ctx.throw(401, "用戶名或密碼不正確"); } const { _id, name } = user; const token = jsonwebtoken.sign({ _id, name }, secret, { expiresIn: "1d" }); // 登陸成功返回jwt加密後的token信息 ctx.body = { token }; } async checkUserExist(ctx, next) { // 查詢用戶是否存在 const user = await User.findById(ctx.params.id); if (!user) { ctx.throw(404, "用戶不存在"); } await next(); } } module.exports = new UserController();
postman演示
登陸
獲取用戶列表
獲取特定用戶
建立用戶
更新用戶信息
刪除用戶
到這裏本篇文章內容也就結束了,這裏主要是結合用戶模塊來給你們講述一下RESTful API最佳實踐
在 koa 項目中的運用。項目的源碼已經開源,地址是https://github.com/Jack-cool/rest_node_api
。須要的自取,感受不錯的話麻煩給個 star!!
同時你能夠關注個人同名公衆號【前端森林】,這裏我會按期發一些大前端相關的前沿文章和平常開發過程當中的實戰總結。