1. 程式人生 > 實用技巧 >node+koa2+mongodb搭建RESTful API風格後臺

node+koa2+mongodb搭建RESTful API風格後臺

RESTful API風格

在開發之前先回顧一下,RESTful API 是什麼? RESTful 是一種 API 設計風格,並不是一種強制規範和標準,它的特點在於請求和響應都簡潔清晰,可讀性強。不管 API 屬於哪種風格,只要能夠滿足需要,就足夠了。API 格式並不存在絕對的標準,只存在不同的設計風格。

API 風格

一般來說 API 設計包含兩部分: 請求和響應。

  • 請求:請求URL、請求方法、請求頭部資訊等。
  • 響應:響應體和響應頭部資訊。

先來看一個請求 url 的組成:

https://www.baidu.com:443/api/articles?id=1
// 請求方法:GET
// 請求協議:protocal: https
// 請求埠:port: 443
// 請求域名:host: www.baidu.com
// 請求路徑:pathname: /api/articles
// 查詢字串:search: id=1

根據 URL 組成部分:請求方法、請求路徑和查詢字串,我們有幾種常見的 API 風格。比如當刪除 id=1 的作者編寫的類別為 2 的所有文章時:

// 純請求路徑
GET https://www.baidu.com/api/articles/delete/authors/1/categories/2
// 一級使用路徑,二級以後使用查詢字串
GET  https://www.baidu.com/api/articles/delete/author/1?category=2
// 純查詢字串
GET  https://www.baidu.com/api/deleteArticles?author=1&category=2
// RESTful風格
DELETE  https://www.baidu.com/api/articles?author=1&category=2

前面三種都是 GET 請求,主要的區別在於多個查詢條件時怎麼傳遞查詢字串,有的通過使用解析路徑,有的通過解析傳參,有的兩者混用。同時在描述 API 功能時,可以使用 articles/delete ,也可以使用 deleteArticles 。而第四種 RESTful API 最大的區別在於行為動詞 DELETE 的位置,不在 url 裡,而在請求方法中.

RESTful設計風格

REST(Representational State Transfer 表現層狀態轉移) 是一種設計風格,而不是標準。主要用於客戶端和服務端的API互動,我認為它的約定大於它的定義,使得api在設計上有了一定的規範和原則,語義更加明確,清晰。

我們一起來看看 RESTFul API 有哪些特點:

  • 基於“資源”,資料也好、服務也好,在 RESTFul 設計裡一切都是資源,
    資源用 URI(Universal Resource Identifier 通用資源標識) 來表示。
  • 無狀態性。
  • URL 中通常不出現動詞,只有名詞。
  • URL 語義清晰、明確。
  • 使用 HTTPGETPOSTDELETEPUT 來表示對於資源的 增刪改查
  • 使用 JSON 不使用 XML

舉個栗子,也就是後面要實現的 api 介面:

GET      /api/blogs:查詢文章
POST     /api/blogs:新建文章
GET       /api/blogs/ID:獲取某篇指定文章
PUT       /api/blogs/ID:更新某篇指定文章
DELETE   /api/blogs/ID:刪除某篇指定文章

關於更多RESTful API 的知識,小夥伴們可以戳:這裡

專案初始化

什麼是Koa2

Koa官方網址。官方介紹:Koa 是一個新的 web 框架,由 Express 幕後的原班人馬打造, 致力於成為 web 應用和 API 開發領域中的一個更小、更富有表現力、更健壯的基石。

Koa2 的安裝與使用對 Node.js 的版本也是有要求的,因為 node.js 7.6 版本 開始完全支援 async/await,所以才能完全支援 Koa2

Koa2Koa 框架的最新版本,Koa3 還沒有正式推出,Koa1.X 正走在被替換的路上。Koa2Koa1 的最大不同,在於 Koa1 基於 co 管理 Promise/Generator 中介軟體,而 Koa2 緊跟最新的 ES 規範,支援到了 Async FunctionKoa1 不支援),兩者中介軟體模型表現一致,只是語法底層不同。

Express 裡面,不同時期的程式碼組織方式雖然大為不同,但縱觀 Express 多年的歷程,他依然是相對大而全,API 較為豐富的框架,它的整個中介軟體模型是基於 callback 回撥,而 callback 常年被人詬病。

簡單來說 KoaExpress 的最大的區別在於 執行順序非同步的寫法 ,同時這也映射了 js 語法在處理非同步任務上的發展歷程。關於非同步和兩種框架區別,不在這裡做過多探討。來看看 Koa 中介軟體洋蔥圈模型:

建立Koa2專案

建立檔案 blog-api ,進入到該目錄:

npm init

安裝 Koa:

yarn add koa

安裝 eslint, 這個選擇安裝,可以根據自己的需求來規範自己的程式碼,下面是我配置的 eslint:

yarn add eslint -D
yarn add eslint-config-airbnb-base -D
yarn add eslint-plugin-import -D

根目錄下新建檔案 .eslintrc.js.editorconfig:

// .eslintrc.js

module.exports = {
  root: true,
  globals: {
    document: true,
  },
  extends: 'airbnb-base',
  rules: {
    'no-underscore-dangle': 0,
    'func-names': 0,
    'no-plusplus': 0,
  },
};
// .editorconfig

root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

在根目錄下新建檔案 app.js

const Koa = require('koa');

const app = new Koa();

app.use(async (ctx) => {
  ctx.body = 'Hello World';
});

app.listen(3000);

通過命令啟動專案:

node app.js

在瀏覽器開啟 http://localhost:3000/:

專案開發

目錄結構

規劃專案結構,建立對應的資料夾:

blog-api
├── bin    // 專案啟動檔案
├── config   // 專案配置檔案
├── controllers    // 控制器
├── dbhelper    // 資料庫操作
├── error    // 錯誤處理
├── middleware    // 中介軟體
├── models    // 資料庫模型
├── node_modules  
├── routers    // 路由
├── util    // 工具類
├── README.md    // 說明文件
├── package.json
├── app.js    // 入口檔案
└── yarn.lock

自動重啟

在編寫除錯專案,修改程式碼後,需要頻繁的手動close掉,然後再重新啟動,非常繁瑣。安裝自動重啟工具 nodemon

yarn add nodemon -D

再安裝 cross-env,主要為設定環境變數相容用的 :

yarn add cross-env

package.jsonscripts 中增加指令碼:

{
  "name": "blog-api",
  "version": "1.0.0",
  "description": "個人部落格後臺api",
  "main": "app.js",
  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon ./app.js",
    "rc": "cross-env NODE_ENV=production nodemon ./app.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Mingme <[email protected]>",
  "license": "ISC",
  "dependencies": {
    "cross-env": "^7.0.2",
    "koa": "^2.13.0",
    "koa-router": "^10.0.0"
  },
  "devDependencies": {
    "eslint": "^7.13.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-plugin-import": "^2.22.1",
    "nodemon": "^2.0.6"
  }
}

這時候就能通過我們設定的指令碼執行專案,修改檔案儲存後就會自動重啟了。

以生產模式執行

yarn rc
// 或者
npm run rc

以開發模式執行

yarn dev
// 或者
npm run dev

koa 路由

路由(Routing)是由一個 URI(或者叫路徑) 和一個特定的 HTTP 方法 (GET、POST 等) 組成的,涉及到應用如何響應客戶端對某個網站節點的訪問。

yarn add koa-router

介面統一以 /api 為字首,比如:

http://localhost:3000/api/categories
http://localhost:3000/api/blogs

config 目錄下建立 index.js:

// config/index.js
module.exports = {
  apiPrefix: '/api',
};

routers 目錄下建立 index.js , category.js , blog.js :

// routers/category.js
const router = require('koa-router')();

router.get('/', async (ctx) => {
  // ctx  上下文 context ,包含了request 和response等資訊
  ctx.body = '我是分類介面';
});

module.exports = router;
// routers/blog.js
const router = require('koa-router')();

router.get('/', async (ctx) => {
  ctx.body = '我是文章介面';
});

module.exports = router;
// routers/index.js
const router = require('koa-router')();
const { apiPrefix } = require('../config/index');

const blog = require('./blog');
const category = require('./category');

router.prefix(apiPrefix);

router.use('/blogs', blog.routes(), blog.allowedMethods());
router.use('/categories', category.routes(), category.allowedMethods());

module.exports = router;

app.js 中修改程式碼,引入路由:

// app.js
const Koa = require('koa');

const app = new Koa();

const routers = require('./routers/index');

// routers
app.use(routers.routes()).use(routers.allowedMethods());

app.listen(3000);

本地啟動專案,看看效果:

根據不同的路由顯示不同的內容,說明路由沒問題了。

GET 請求

接下來看一下引數傳遞,假如是請求 id1 的文章,我們 GET 請求一般這麼寫:

http://localhost:3000/api/blogs/1
http://localhost:3000/api/blogs?id=1
// routers/blog.js
const router = require('koa-router')();

router.get('/', async (ctx) => {
  /**
    在 koa2 中 GET 傳值通過 request 接收,但是接收的方法有兩種:query 和 querystring。
    query:返回的是格式化好的引數物件。
    querystring:返回的是請求字串。
  */
  ctx.body = `我是文章介面id: ${ctx.query.id}`;
});

// 動態路由
router.get('/:id', async (ctx) => {
  ctx.body = `動態路由文章介面id: ${ctx.params.id}`;
});

module.exports = router;

如圖:

POST/PUT/DEL

GET 把引數包含在 URL 中,POST 通過 request body 傳遞引數。
為了方便使用 koa-body 來處理 POST 請求和檔案上傳,或者使用 koa-bodyparserkoa-multer 也可以。

yarn add koa-body

為了統一資料格式,使資料JSON化,安裝 koa-json:

yarn add koa-json

使用 koa-logger 方便除錯:

yarn add koa-logger

app.js 裡引入中介軟體:

const Koa = require('koa');

const path = require('path');

const app = new Koa();
const koaBody = require('koa-body');
const json = require('koa-json');
const logger = require('koa-logger');

const routers = require('./routers/index');

// middlewares
app.use(koaBody({
  multipart: true, // 支援檔案上傳
  formidable: {
    formidable: {
      uploadDir: path.join(__dirname, 'public/upload/'), // 設定檔案上傳目錄
      keepExtensions: true, // 保持檔案的字尾
      maxFieldsSize: 2 * 1024 * 1024, // 檔案上傳大小
      onFileBegin: (name, file) => { // 檔案上傳前的設定
        console.log(`name: ${name}`);
        console.log(file);
      },
    },
  },
}));
app.use(json());
app.use(logger());

// routers
app.use(routers.routes()).use(routers.allowedMethods());

app.listen(3000);

routers/blog.js 下新增路由:

// routers/blog.js

const router = require('koa-router')();

router.get('/', async (ctx) => {
  ctx.body = `我是文章介面id: ${ctx.query.id}`;
});

// 動態路由
router.get('/:id', async (ctx) => {
  ctx.body = `動態路由文章介面id: ${ctx.params.id}`;
});

router.post('/', async (ctx) => {
  ctx.body = ctx.request.body;
});

router.put('/:id', async (ctx) => {
  ctx.body = `PUT: ${ctx.params.id}`;
});

router.del('/:id', async (ctx) => {
  ctx.body = `DEL: ${ctx.params.id}`;
});

module.exports = router;

測試一下:


錯誤處理

在請求過程中,還需要將返回結果進行一下包裝,發生異常時,如果介面沒有提示語,狀態碼的返回肯定是不友好的,下面定義幾個常用的錯誤型別。
error 目錄下建立 api_error_map.jsapi_error_name.jsapi_error.js:

// error/api_error_map.js

const ApiErrorNames = require('./api_error_name');

const ApiErrorMap = new Map();

ApiErrorMap.set(ApiErrorNames.NOT_FOUND, { code: ApiErrorNames.NOT_FOUND, message: '未找到該介面' });
ApiErrorMap.set(ApiErrorNames.UNKNOW_ERROR, { code: ApiErrorNames.UNKNOW_ERROR, message: '未知錯誤' });
ApiErrorMap.set(ApiErrorNames.LEGAL_ID, { code: ApiErrorNames.LEGAL_ID, message: 'id 不合法' });
ApiErrorMap.set(ApiErrorNames.UNEXIST_ID, { code: ApiErrorNames.UNEXIST_ID, message: 'id 不存在' });
ApiErrorMap.set(ApiErrorNames.LEGAL_FILE_TYPE, { code: ApiErrorNames.LEGAL_FILE_TYPE, message: '檔案型別不允許' });
ApiErrorMap.set(ApiErrorNames.NO_AUTH, { code: ApiErrorNames.NO_AUTH, message: '沒有操作許可權' });

module.exports = ApiErrorMap;
// error/api_error_name.js

const ApiErrorNames = {
  NOT_FOUND: 'not_found',
  UNKNOW_ERROR: 'unknow_error',
  LEGAL_ID: 'legal_id',
  UNEXIST_ID: 'unexist_id',
  LEGAL_FILE_TYPE: 'legal_file_type',
  NO_AUTH: 'no_auth',
};

module.exports = ApiErrorNames;
// error/api_error.js

const ApiErrorMap = require('./api_error_map');

/**
 * 自定義Api異常
 */

class ApiError extends Error {
  constructor(errorName, errorMsg) {
    super();

    let errorInfo = {};
    if (errorMsg) {
      errorInfo = {
        code: errorName,
        message: errorMsg,
      };
    } else {
      errorInfo = ApiErrorMap.get(errorName);
    }

    this.name = errorName;
    this.code = errorInfo.code;
    this.message = errorInfo.message;
  }
}

module.exports = ApiError;

middleware 目錄下建立 response_formatter.js 用來處理 api 返回資料的格式化:

// middleware/response_formatter.js

const ApiError = require('../error/api_error');
const ApiErrorNames = require('../error/api_error_name');

const responseFormatter = (apiPrefix) => async (ctx, next) => {
  if (ctx.request.path.startsWith(apiPrefix)) {
    try {
      // 先去執行路由
      await next();

      if (ctx.response.status === 404) {
        throw new ApiError(ApiErrorNames.NOT_FOUND);
      } else {
        ctx.body = {
          code: 'success',
          message: '成功!',
          result: ctx.body,
        };
      }
    } catch (error) {
      // 如果異常型別是API異常,將錯誤資訊新增到響應體中返回。
      if (error instanceof ApiError) {
        ctx.body = {
          code: error.code,
          message: error.message,
        };
      } else {
        ctx.status = 400;
        ctx.response.body = {
          code: error.name,
          message: error.message,
        };
      }
    }
  } else {
    await next();
  }
};

module.exports = responseFormatter;

順便安裝 koa 的錯誤處理程式 hack

yarn add koa-onerror

app.js 中新增程式碼:

const Koa = require('koa');

const path = require('path');

const app = new Koa();
const onerror = require('koa-onerror');
const koaBody = require('koa-body');
const json = require('koa-json');
const logger = require('koa-logger');

const responseFormatter = require('./middleware/response_formatter');
const { apiPrefix } = require('./config/index');
const routers = require('./routers/index');

// koa的錯誤處理程式hack
onerror(app);

// middlewares
app.use(koaBody({
  multipart: true, // 支援檔案上傳
  formidable: {
    formidable: {
      uploadDir: path.join(__dirname, 'public/upload/'), // 設定檔案上傳目錄
      keepExtensions: true, // 保持檔案的字尾
      maxFieldsSize: 2 * 1024 * 1024, // 檔案上傳大小
      onFileBegin: (name, file) => { // 檔案上傳前的設定
        console.log(`name: ${name}`);
        console.log(file);
      },
    },
  },
}));
app.use(json());
app.use(logger());

// response formatter
app.use(responseFormatter(apiPrefix));

// routers
app.use(routers.routes()).use(routers.allowedMethods());

// 監聽error
app.on('error', (err, ctx) => {
  // 在這裡可以對錯誤資訊進行一些處理,生成日誌等。
  console.error('server error', err, ctx);
});

app.listen(3000);

在後續開發中,若遇到異常,將異常丟擲即可。

連線資料庫

mongoDB 資料庫的安裝教程 :Linux 伺服器(CentOS)安裝配置mongodb+node

mongoosenodeJS 提供連線 mongodb 的一個庫。

mongoose-paginatemongoose 的分頁外掛。

mongoose-unique-validator :可為 Mongoose schema 中的唯一欄位新增預儲存驗證。

yarn add mongoose
yarn add mongoose-paginate
yarn add mongoose-unique-validator

config/index.js 中增加配置:

module.exports = {
  port: process.env.PORT || 3000,
  apiPrefix: '/api',
  database: 'mongodb://localhost:27017/test',
  databasePro: 'mongodb://root:[email protected]:27017/blog', // mongodb://使用者名稱:密碼@伺服器公網IP:埠/庫的名稱
};

dbhelper 目錄下建立 db.js:

const mongoose = require('mongoose');
const config = require('../config');

mongoose.Promise = global.Promise;

const IS_PROD = ['production', 'prod', 'pro'].includes(process.env.NODE_ENV);
const databaseUrl = IS_PROD ? config.databasePro : config.database;

/**
 *  連線資料庫
 */

mongoose.connect(databaseUrl, {
  useUnifiedTopology: true,
  useNewUrlParser: true,
  useFindAndModify: false,
  useCreateIndex: true,
  config: {
    autoIndex: false,
  },
});

/**
 *  連線成功
 */

mongoose.connection.on('connected', () => {
  console.log(`Mongoose 連線成功: ${databaseUrl}`);
});

/**
 *  連線異常
 */

mongoose.connection.on('error', (err) => {
  console.log(`Mongoose 連接出錯: ${err}`);
});

/**
 *  連線斷開
 */

mongoose.connection.on('disconnected', () => {
  console.log('Mongoose 連線關閉!');
});

module.exports = mongoose;

app.js 中引入:

...
const routers = require('./routers/index');

require('./dbhelper/db');

// koa的錯誤處理程式hack
onerror(app);
...

啟動專案就可以看到log提示連線成功:

這裡說一下在 db.js 中有這麼一行程式碼:

mongoose.Promise = global.Promise;

加上這個是因為:mongoose 的所有查詢操作返回的結果都是 querymongoose 封裝的一個物件,並非一個完整的 promise,而且與 ES6 標準的 promise 有所出入,因此在使用 mongoose 的時候,一般加上這句 mongoose.Promise = global.Promise

開發 API

Mongoose 的一切始於 Schema 。在開發介面之前,那就先來構建模型,這裡主要構建文章分類,和文章列表兩種型別的介面,在欄位上會比較簡陋,主要用於舉例使用,小夥伴們可以舉一反三。
models 目錄下建立 category.jsblog.js:

// models/category.js

const mongoose = require('mongoose');
const mongoosePaginate = require('mongoose-paginate');
const uniqueValidator = require('mongoose-unique-validator');

const schema = new mongoose.Schema({
  name: {
    type: String,
    unique: true,
    required: [true, '分類 name 必填'],
  },
  value: {
    type: String,
    unique: true,
    required: [true, '分類 value 必填'],
  },
  rank: {
    type: Number,
    default: 0,
  },
}, {
  timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' },
});

// 自動增加版本號
/* Mongoose 僅在您使用時更新版本金鑰save()。如果您使用update(),findOneAndUpdate()等等,Mongoose將不會 更新版本金鑰。
作為解決方法,您可以使用以下中介軟體。參考 https://mongoosejs.com/docs/guide.html#versionKey */

schema.pre('findOneAndUpdate', function () {
  const update = this.getUpdate();
  if (update.__v != null) {
    delete update.__v;
  }
  const keys = ['$set', '$setOnInsert'];
  Object.keys(keys).forEach((key) => {
    if (update[key] != null && update[key].__v != null) {
      delete update[key].__v;
      if (Object.keys(update[key]).length === 0) {
        delete update[key];
      }
    }
  });
  update.$inc = update.$inc || {};
  update.$inc.__v = 1;
});

schema.plugin(mongoosePaginate);
schema.plugin(uniqueValidator);

module.exports = mongoose.model('Category', schema);
// models/blog.js

const mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const mongoosePaginate = require('mongoose-paginate');

const schema = new mongoose.Schema({
  title: {
    type: String,
    unique: true,
    required: [true, '必填欄位'],
  }, // 標題
  content: {
    type: String,
    required: [true, '必填欄位'],
  }, // 內容
  category: {
    type: mongoose.Schema.Types.ObjectId,
    required: [true, '必填欄位'],
    ref: 'Category',
  }, // 分類_id,根據這個id我們就能從 category 表中查詢到相關資料。
  status: {
    type: Boolean,
    default: true,
  }, // 狀態
}, {
  timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' },
  toJSON: { virtuals: true },
});

// 虛擬欄位:根據_id查詢對應表中的資料。
schema.virtual('categoryObj', {
  ref: 'Category',
  localField: 'category',
  foreignField: '_id',
  justOne: true,
});

// 自動增加版本號
/* Mongoose 僅在您使用時更新版本金鑰save()。如果您使用update(),findOneAndUpdate()等等,Mongoose將不會 更新版本金鑰。
作為解決方法,您可以使用以下中介軟體。參考 https://mongoosejs.com/docs/guide.html#versionKey */

schema.pre('findOneAndUpdate', function () {
  const update = this.getUpdate();
  if (update.__v != null) {
    delete update.__v;
  }
  const keys = ['$set', '$setOnInsert'];
  Object.keys(keys).forEach((key) => {
    if (update[key] != null && update[key].__v != null) {
      delete update[key].__v;
      if (Object.keys(update[key]).length === 0) {
        delete update[key];
      }
    }
  });
  update.$inc = update.$inc || {};
  update.$inc.__v = 1;
});

schema.plugin(mongoosePaginate);
schema.plugin(uniqueValidator);

module.exports = mongoose.model('Blog', schema);

dbhelper 目錄下,定義一些對資料庫增刪改查的方法,建立 category.jsblog.js:

// dbhelper/category.js

const Model = require('../models/category');

// TODO: 此檔案中最好返回 Promise。通過 .exec() 可以返回 Promise。
// 需要注意的是 分頁外掛本身返回的就是 Promise 因此 Model.paginate 不需要 exec()。
// Model.create 返回的也是 Promise

/**
 * 查詢全部
 */
exports.findAll = () => Model.find().sort({ rank: 1 }).exec();

/**
 * 查詢多個 篩選
 */
exports.findSome = (data) => {
  const {
    page = 1, limit = 10, sort = 'rank',
  } = data;
  const query = {};
  const options = {
    page: parseInt(page, 10),
    limit: parseInt(limit, 10),
    sort,
  };
  const result = Model.paginate(query, options);

  return result;
};

/**
 * 查詢單個 詳情
 */
exports.findById = (id) => Model.findById(id).exec();

/**
 * 增加
 */
exports.add = (data) => Model.create(data);

/**
 * 更新
 */
exports.update = (data) => {
  const { id, ...restData } = data;
  return Model.findOneAndUpdate({ _id: id }, {
    ...restData,
  },
  {
    new: true, // 返回修改後的資料
  }).exec();
};

/**
 * 刪除
 */
exports.delete = (id) => Model.findByIdAndDelete(id).exec();
// dbhelper/blog.js

const Model = require('../models/blog');

// TODO: 此檔案中最好返回 Promise。通過 .exec() 可以返回 Promise。
// 需要注意的是 分頁外掛本身返回的就是 Promise 因此 Model.paginate 不需要 exec()。
// Model.create 返回的也是 Promise

const populateObj = [
  {
    path: 'categoryObj',
    select: 'name value',
  },
];

/**
 * 查詢全部
 */
exports.findAll = () => Model.find().populate(populateObj).exec();

/**
 * 查詢多個 篩選
 */
exports.findSome = (data) => {
  const {
    keyword, title, category, status = true, page = 1, limit = 10, sort = '-createdAt',
  } = data;
  const query = {};
  const options = {
    page: parseInt(page, 10),
    limit: parseInt(limit, 10),
    sort,
    populate: populateObj,
  };

  if (status !== 'all') {
    query.status = status === true || status === 'true';
  }

  if (title) {
    query.title = { $regex: new RegExp(title, 'i') };
  }

  if (category) {
    query.category = category;
  }

  // 關鍵字模糊查詢 標題 和 content
  if (keyword) {
    const reg = new RegExp(keyword, 'i');
    const fuzzyQueryArray = [{ content: { $regex: reg } }];
    if (!title) {
      fuzzyQueryArray.push({ title: { $regex: reg } });
    }
    query.$or = fuzzyQueryArray;
  }

  return Model.paginate(query, options);
};

/**
 * 查詢單個 詳情
 */
exports.findById = (id) => Model.findById(id).populate(populateObj).exec();

/**
 * 新增
 */
exports.add = (data) => Model.create(data);

/**
 * 更新
 */
exports.update = (data) => {
  const { id, ...restData } = data;
  return Model.findOneAndUpdate({ _id: id }, {
    ...restData,
  }, {
    new: true, // 返回修改後的資料
  }).exec();
};

/**
 * 刪除
 */
exports.delete = (id) => Model.findByIdAndDelete(id).exec();

編寫路由:

// routers/category.js

const router = require('koa-router')();
const controller = require('../controllers/category');

// 查
router.get('/', controller.find);

// 查 動態路由
router.get('/:id', controller.detail);

// 增
router.post('/', controller.add);

// 改
router.put('/:id', controller.update);

// 刪
router.del('/:id', controller.delete);

module.exports = router;
// routers/blog.js

const router = require('koa-router')();
const controller = require('../controllers/blog');

// 查
router.get('/', controller.find);

// 查 動態路由
router.get('/:id', controller.detail);

// 增
router.post('/', controller.add);

// 改
router.put('/:id', controller.update);

// 刪
router.del('/:id', controller.delete);

module.exports = router;

在路由檔案裡面我們只定義路由,把路由所對應的方法全部都放在 controllers 下:

// controllers/category.js
const dbHelper = require('../dbhelper/category');
const tool = require('../util/tool');

const ApiError = require('../error/api_error');
const ApiErrorNames = require('../error/api_error_name');

/**
 * 查
 */
exports.find = async (ctx) => {
  let result;
  const reqQuery = ctx.query;

  if (reqQuery && !tool.isEmptyObject(reqQuery)) {
    if (reqQuery.id) {
      result = dbHelper.findById(reqQuery.id);
    } else {
      result = dbHelper.findSome(reqQuery);
    }
  } else {
    result = dbHelper.findAll();
  }

  await result.then((res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 查 動態路由 id
 */
exports.detail = async (ctx) => {
  const { id } = ctx.params;
  if (!tool.validatorsFun.numberAndCharacter(id)) {
    throw new ApiError(ApiErrorNames.LEGAL_ID);
  }
  await dbHelper.findById(id).then((res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 新增
 */
exports.add = async (ctx) => {
  const dataObj = ctx.request.body;

  await dbHelper.add(dataObj).then((res) => {
    ctx.body = res;
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 更新
 */
exports.update = async (ctx) => {
  const ctxParams = ctx.params;
  // 合併 路由中的引數 以及 傳送過來的引數
  // 路由引數 以及傳送的引數可能都有 id 以 傳送的 id 為準,如果沒有,取路由中的 id
  const dataObj = { ...ctxParams, ...ctx.request.body };

  await dbHelper.update(dataObj).then((res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 刪除
 */
exports.delete = async (ctx) => {
  const ctxParams = ctx.params;
  // 合併 路由中的引數 以及 傳送過來的引數
  // 路由引數 以及傳送的引數可能都有 id 以 傳送的 id 為準,如果沒有,取路由中的 id
  const dataObj = { ...ctxParams, ...ctx.request.body };
  if (!tool.validatorsFun.numberAndCharacter(dataObj.id)) {
    throw new ApiError(ApiErrorNames.LEGAL_ID);
  }

  await dbHelper.delete(dataObj.id).then((res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};
// controllers/blog.js
const dbHelper = require('../dbhelper/blog');
const tool = require('../util/tool');

const ApiError = require('../error/api_error');
const ApiErrorNames = require('../error/api_error_name');

/**
 * 查
 */
exports.find = async (ctx) => {
  let result;
  const reqQuery = ctx.query;

  if (reqQuery && !tool.isEmptyObject(reqQuery)) {
    if (reqQuery.id) {
      result = dbHelper.findById(reqQuery.id);
    } else {
      result = dbHelper.findSome(reqQuery);
    }
  } else {
    result = dbHelper.findAll();
  }

  await result.then((res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 查 詳情
 */
exports.detail = async (ctx) => {
  const { id } = ctx.params;
  if (!tool.validatorsFun.numberAndCharacter(id)) {
    throw new ApiError(ApiErrorNames.LEGAL_ID);
  }

  await dbHelper.findById(id).then(async (res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 增
 */
exports.add = async (ctx) => {
  const dataObj = ctx.request.body;

  await dbHelper.add(dataObj).then((res) => {
    ctx.body = res;
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 改
 */
exports.update = async (ctx) => {
  const ctxParams = ctx.params;
  // 合併 路由中的引數 以及 傳送過來的引數
  // 路由引數 以及傳送的引數可能都有 id 以 傳送的 id 為準,如果沒有,取路由中的 id
  const dataObj = { ...ctxParams, ...ctx.request.body };

  await dbHelper.update(dataObj).then((res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * 刪
 */
exports.delete = async (ctx) => {
  const ctxParams = ctx.params;
  // 合併 路由中的引數 以及 傳送過來的引數
  // 路由引數 以及傳送的引數可能都有 id 以 傳送的 id 為準,如果沒有,取路由中的 id
  const dataObj = { ...ctxParams, ...ctx.request.body };
  if (!tool.validatorsFun.numberAndCharacter(dataObj.id)) {
    throw new ApiError(ApiErrorNames.LEGAL_ID);
  }

  await dbHelper.delete(dataObj.id).then((res) => {
    if (res) {
      ctx.body = res;
    } else {
      throw new ApiError(ApiErrorNames.UNEXIST_ID);
    }
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

上面使用了兩個方法,isEmptyObject 判斷是否是空物件,numberAndCharacterid 格式做一個簡單的檢查。

// util/tool.js
/**
 * @desc 檢查是否為空物件
 */
exports.isEmptyObject = (obj) => Object.keys(obj).length === 0;

/**
 * @desc 常規正則校驗表示式
 */
exports.validatorsExp = {
  number: /^[0-9]*$/,
  numberAndCharacter: /^[0-9a-zA-Z]+$/,
  nameLength: (n) => new RegExp(`^[\\u4E00-\\u9FA5]{${n},}$`),
  idCard: /^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9]|X)$/,
  backCard: /^([1-9]{1})(\d{15}|\d{18})$/,
  phone: /^1[3456789]\d{9}$/,
  email: /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/,
};

/**
 * @desc 常規正則校驗方法
 */
exports.validatorsFun = {
  number: (val) => exports.validatorsExp.number.test(val),
  numberAndCharacter: (val) => exports.validatorsExp.numberAndCharacter.test(val),
  idCard: (val) => exports.validatorsExp.idCard.test(val),
  backCard: (val) => exports.validatorsExp.backCard.test(val),
};

到此,分類和文章相關的介面基本完成了,測試一下:


鑑權

這裡我使用的是 token 來進行身份驗證的: jsonwebtoken
根據路由對一些非 GET 請求的介面做 token 驗證。

// app.js
...
// 檢查請求時 token 是否過期
app.use(tokenHelper.checkToken([
  '/api/blogs',
  '/api/categories',
  ...
], [
  '/api/users/signup',
  '/api/users/signin',
  '/api/users/forgetPwd',
]));
...
// util/token-helper.js

const jwt = require('jsonwebtoken');
const config = require('../config/index');
const tool = require('./tool');

// 生成token
exports.createToken = (user) => {
  const token = jwt.sign({ userId: user._id, userName: user.userName }, config.tokenSecret, { expiresIn: '2h' });
  return token;
};

// 解密token返回userId,userName用來判斷使用者身份。
exports.decodeToken = (ctx) => {
  const token = tool.getTokenFromCtx(ctx);
  const userObj = jwt.decode(token, config.tokenSecret);
  return userObj;
};

// 檢查token
exports.checkToken = (shouldCheckPathArray, unlessCheckPathArray) => async (ctx, next) => {
  const currentUrl = ctx.request.url;
  const { method } = ctx.request;

  const unlessCheck = unlessCheckPathArray.some((url) => currentUrl.indexOf(url) > -1);

  const shouldCheck = shouldCheckPathArray.some((url) => currentUrl.indexOf(url) > -1) && method !== 'GET';

  if (shouldCheck && !unlessCheck) {
    const token = tool.getTokenFromCtx(ctx);
    if (token) {
      try {
        jwt.verify(token, config.tokenSecret);
        await next();
      } catch (error) {
        ctx.status = 401;
        ctx.body = 'token 過期';
      }
    } else {
      ctx.status = 401;
      ctx.body = '無 token,請登入';
    }
  } else {
    await next();
  }
};

在註冊個登入的時候生成設定 token

// controllers/users.js

/**
 * @desc 註冊
 */
 ...
exports.signUp = async (ctx) => {
  const dataObj = ctx.request.body;

  await dbHelper.signUp(dataObj).then((res) => {
    const token = tokenHelper.createToken(res);
    const { password, ...restData } = res._doc;
    ctx.res.setHeader('Authorization', token);
    ctx.body = {
      token,
      ...restData,
    };
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};

/**
 * @desc 登入
 */
exports.signIn = async (ctx) => {
  const dataObj = ctx.request.body;

  await dbHelper.signIn(dataObj).then((res) => {
    const token = tokenHelper.createToken(res);
    const { password, ...restData } = res;
    ctx.res.setHeader('Authorization', token);
    ctx.body = {
      token,
      ...restData,
    };
  }).catch((err) => {
    throw new ApiError(err.name, err.message);
  });
};
...

專案部署

部署就比較簡單了,將專案檔案全部上傳到伺服器上,然後全域性安裝 pm2,用 pm2 啟動即可。

bin 目錄下建立 pm2.config.json :

{
  "apps": [
    {
      "name": "blog-api",
      "script": "./app.js",
      "instances": 0,
      "watch": false,
      "exec_mode": "cluster_mode"
    }
  ]
}

package.json 中新增啟動指令碼:

{
  ...
  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon ./app.js",
    "rc": "cross-env NODE_ENV=production nodemon ./app.js",
    "pm2": "cross-env NODE_ENV=production NODE_LOG_DIR=/tmp ENABLE_NODE_LOG=YES pm2 start ./bin/pm2.config.json",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
 ...
}

然後,cd 到專案根目錄:

npm run pm2

關於個人部落格前臺開發可以戳這裡:Nuxt 開發搭建部落格