Koa2+MongoDB+JWT實戰--Restful API最佳實踐

restful_banner.jpeg

引言

Web API 已經在最近幾年變成重要的話題,一個乾淨的 API 設計對於後端系統是很是重要的。html

一般咱們爲 Web API 使用 RESTful 設計,REST 概念分離了 API 結構邏輯資源,經過 Http 方法GET, DELETE, POSTPUT等 來操做資源。前端

本篇文章是結合我最近的一個項目,基於koa+mongodb+jwt來給你們講述一下 RESTful API 的最佳實踐。node

RESTful API 是什麼?

具體瞭解RESTful API前,讓咱們先來看一下什麼是RESTjquery

REST的全稱是Representational state transfer。具體以下:git

  • Representational: 數據的表現形式(JSON、XML...)
  • state: 當前狀態或者數據
  • transfer: 數據傳輸

它描述了一個系統如何與另外一個交流。好比一個產品的狀態(名字,詳情)表現爲 XML,JSON 或者普通文本。github

REST 有六個約束:web

  • 客戶-服務器(Client-Server)

    關注點分離。服務端專一數據存儲,提高了簡單性,前端專一用戶界面,提高了可移植性。mongodb

  • 無狀態(Stateless)

    全部用戶會話信息都保存在客戶端。每次請求必須包括全部信息,不能依賴上下文信息。服務端不用保存會話信息,提高了簡單性、可靠性、可見性。數據庫

  • 緩存(Cache)

    全部服務端響應都要被標爲可緩存或不可緩存,減小先後端交互,提高了性能。npm

  • 統一接口(Uniform Interface)

    接口設計儘量統一通用,提高了簡單性、可見性。接口與實現解耦,使先後端能夠獨立開發迭代。

  • 分層系統(Layered System)
  • 按需代碼(Code-On-Demand)

看完了 REST 的六個約束,下面讓咱們來看一下行業內對於RESTful API設計最佳實踐的總結。

最佳實踐

請求設計規範

  • URI 使用名詞,儘可能使用複數,如/users
  • URI 使用嵌套表示關聯關係,如/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最佳實踐吧。

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        # 路由

項目的目錄呈現了清晰的分層、分模塊結構,也便於後期的維護和擴展。下面咱們會對項目中須要注意的幾點一一說明。

Controller(控制器)

什麼是控制器?

  • 拿到路由分配的任務並執行
  • 在 koa 中是一箇中間件

爲何要用控制器

  • 獲取 HTTP 請求參數

    • Query String,如?q=keyword
    • Router Params,如/users/:id
    • Body,如{name: 'jack'}
    • Header,如 Accept、Cookie
  • 處理業務邏輯
  • 發送 HTTP 響應

    • 發送 Status,如 200/400
    • 發送 Body,如{name: 'jack'}
    • 發送 Header,如 Allow、Content-Type

編寫控制器的最佳實踐

  • 每一個資源的控制器放在不一樣的文件裏
  • 儘可能使用類+類方法的形式編寫控制器
  • 嚴謹的錯誤處理

示例

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來處理錯誤,關於該中間件的詳細介紹會在下文展開。

用戶認證與受權

目前經常使用的用於用戶信息認證與受權的有兩種方式-JWTSession。下面咱們分別對比一下兩種鑑權方式的優劣點。

Session

  • 相關的概念介紹

    • session::主要存放在服務器,相對安全
    • cookie:主要存放在客戶端,而且不是很安全
    • sessionStorage:僅在當前會話下有效,關閉頁面或瀏覽器後被清除
    • localstorage:除非被清除,不然永久保存
  • 工做原理

    • 客戶端帶着用戶名和密碼去訪問/login 接口,服務器端收到後校驗用戶名和密碼,校驗正確就會在服務器端存儲一個 sessionId 和 session 的映射關係。
    • 服務器端返回 response,而且將 sessionId 以 set-cookie 的方式種在客戶端,這樣,sessionId 就存在了客戶端。
    • 客戶端發起非登陸請求時,假如服務器給了 set-cookie,瀏覽器會自動在請求頭中添加 cookie。
    • 服務器接收請求,分解 cookie,驗證信息,覈對成功後返回 response 給客戶端。
  • 優點

    • 相比 JWT,最大的優點就在於能夠主動清楚 session 了
    • session 保存在服務器端,相對較爲安全
    • 結合 cookie 使用,較爲靈活,兼容性較好(客戶端服務端均可以清除,也能夠加密)
  • 劣勢

    • cookie+session 在跨域場景表現並很差(不可跨域,domain 變量,須要複雜處理跨域)
    • 若是是分佈式部署,須要作多機共享 Session 機制(成本增長)
    • 基於 cookie 的機制很容易被 CSRF
    • 查詢 Session 信息可能會有數據庫查詢操做

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

    • 安全性(二者均有缺陷)
    • RESTful API,JWT 優勝,由於 RESTful API 提倡無狀態,JWT 符合要求
    • 性能(各有利弊,由於 JWT 信息較強,因此體積也較大。不過 Session 每次都須要服務器查找,JWT 信息都保存好了,不須要再去查詢數據庫)
    • 時效性,Session 能直接從服務端銷燬,JWT 只能等到時效性到了纔會銷燬(修改密碼也沒法阻止篡奪者的使用)

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

mongoosenodeJS提供鏈接 mongodb的一個庫,相似於jqueryjs的關係,對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

項目中的模塊是比較多的,我不會一一去演示,由於各個模塊實質性的內容是大同小異的。在這裏主要是以用戶模塊的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!!

同時你能夠關注個人同名公衆號【前端森林】,這裏我會按期發一些大前端相關的前沿文章和平常開發過程當中的實戰總結。

相關文章
相關標籤/搜索