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

前端森林發表於2020-10-27

restful_banner.jpeg

引言

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

通常我們為 Web API 使用 RESTful 設計,REST 概念分離了 API 結構邏輯資源,通過 Http 方法GET, DELETE, POSTPUT等 來操作資源。

本篇文章是結合我最近的一個專案,基於koa+mongodb+jwt來給大家講述一下 RESTful API 的最佳實踐。

RESTful API 是什麼?

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

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

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

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

REST 有六個約束:

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

    關注點分離。服務端專注資料儲存,提升了簡單性,前端專注使用者介面,提升了可移植性。

  • 無狀態(Stateless)

    所有使用者會話資訊都儲存在客戶端。每次請求必須包括所有資訊,不能依賴上下文資訊。服務端不用儲存會話資訊,提升了簡單性、可靠性、可見性。

  • 快取(Cache)

    所有服務端響應都要被標為可快取或不可快取,減少前後端互動,提升了效能。

  • 統一介面(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.reqBooleanfalse
    patchKoa將請求體打到 koa 的 ctx.requestBooleantrue
    jsonLimitJSON 資料體的大小限制String / Integer1mb
    formLimit限制表單請求體的大小String / Integer24kb
    textLimit限制 text body 的大小String / Integer23kb
    encoding表單的預設編碼Stringutf-8
    multipart是否支援 multipart-formdate 的表單Booleanfalse
    urlencoded是否支援 urlencoded 的表單Booleantrue
    formidable配置更多的關於 multipart 的選項Object{}
    onError錯誤處理Functionfunction(){}
    stict嚴格模式,啟用後不會解析 GET, HEAD, DELETE 請求Booleantrue
  • formidable 的相關配置引數

    引數名描述型別預設值
    maxFields限制欄位的數量Integer500
    maxFieldsSize限制欄位的最大大小Integer1 * 1024 * 1024
    uploadDir檔案上傳的資料夾Stringos.tmpDir()
    keepExtensions保留原來的檔案字尾Booleanfalse
    hash如果要計算檔案的 hash,則可以選擇 md5/sha1Stringfalse
    multipart是否支援多檔案上傳Booleantrue
    onFileBegin檔案上傳前的一些設定操作Functionfunction(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/Cosen95/rest_node_api。需要的自取,感覺不錯的話麻煩給個 star!!

同時你可以關注我的同名公眾號【前端森林】,這裡我會定期發一些大前端相關的前沿文章和日常開發過程中的實戰總結。

image

相關文章