前言
如今nodejs變得越來越火熱,采用nodejs實現前后端分離架構已被多數大公司所采用。
在過去,使用nodejs大家首先想到的是TJ大神寫的express.js,而發展到如今,更輕量,性能更好的koa已然成為主流,
它同樣出自TJ大神手筆,如今版本已更新到了koa2,不僅性能優異,它還支持async/await,堪稱回調地獄的終結者
下面,我們來探討下,如何使用koa2+es6/7來打造高質量的Restful風格API。
刨根問底,篇幅略長,精華在后面,需要耐心看。
1. 兩種模式
一種是耦合模式,即接口層和邏輯層都由一個函數來處理完成。
另一種是分離模式,即接口層和邏輯層是分開的。
下面我們先來說第一種。
耦合模式
先舉個粟子,以express為例:
# /server/user/login.js 用戶登錄
const express = require('express');
const router = express.Router();
router.post('/api/user/login',function(req,res){
// 邏輯層
})
# /server/user/register.js 用戶注冊
const express = require('express');
const router = express.Router();
router.post('/api/user/register',function(req,res){
// 邏輯層
})
# /server/user/put.js 更改用戶資料
const express = require('express');
const router = express.Router();
router.post('/api/user/put',function(req,res){
// 邏輯層
})
這種在過去很常見,相信很多人都寫過,我也不例外。但並不推薦。
首先,一個應用的api通常會很多,如果應用夠復雜,那意味着你的api可能要處理非常多的邏輯。
而為了應付這些需求,你不得不建立很多的文件,甚至困擾於如何划分和組織好這些文件。
其次,后期並不好維護,當api過多,過於繁雜時,文件深層嵌套,也許你找一個api文件都費神費力。
分離模式
同樣先來個粟子:
# /server/router.js
const express = require('express');
const router = express.Router();
router.post('/api/user/login',require('../controllers/users/login')) // 用戶登錄
.post('/api/user/register',require('../controllers/users/register')) // 用戶注冊
.put('/api/user/put',require('../controllers/users/put') // 更改用戶資料
.delete('/api/user/deluser',require('../controllers/users/deluser')) // 刪除用戶
……
很顯然,這種api已將接口層和邏輯層分離了,接口層由一個router.js文件來統一定義,而每個接口的邏輯層則由單獨的文件來處理,並按不同功能模塊用不同文件夾來組織這些邏輯文件。
那么,這樣做有什么好處呢?
首先,很直觀,整個結構很清晰,一目了然
其次,只需要你專注於處理邏輯
再者,api集中在router.js文件定義,同事更容易看懂你的代碼結構,或者知道你增改了哪些api等等,這很方便於多人協同開發,在大型開發中尤為重要
很顯然,分離模式優於耦合模式。
2. 如何更好地組織邏輯層
經過上面的分析之后,我們選擇更優的分離模式, 它只需要你關注邏輯層。
但是,以上面分離模式的例子為例,每一個接口仍然需要單獨一個js文件來處理它的邏輯層,並且需要用很多不同文件夾來組織它
們,假如應用足夠大,有幾十甚至上百個api,那意味着很有可能你的js邏輯文件也達幾十乃至上百個,而用來划分和組織這些js文
件的文件夾也不在少數。
這就造成了過於臃腫,難以維護的毛病。
那么,有沒有可能,一個功能模塊只需要一個js文件來處理它們的所有邏輯層,並更具可維護性呢?
打個比方,現在有一個博客站點,我僅使用一個user.js文件來處理用戶模塊所有api的邏輯層,包括注冊,登錄,修改,刪除,密碼重置等等,另外用一個article.js文件來處理文章模塊所有api的邏輯層,包括發布,修改,獲取詳情,點贊,評論,刪除等等。
如果可以做到這樣,那就意味着代碼量大大減少,且可維護性更高。
而要做到這步,我們需要解決兩個問題,一個是異步回調,因為異步回調使我們增加了很多代碼量,邏輯復雜,二是如何批量定義和導出大量api的邏輯層方法。
首先,我們先來解決異步回調這個問題,下面將會展開講解。
為了減少篇幅,下面只做簡要的淺析。
express 時代
我們先來回顧一下歷史。
鑒於nodejs的回調機制,很多異步操作都需要回調來完成,如果你的邏輯足夠復雜,很可能就會陷進回調地獄,下面是一個簡單的例子:
……
fs.readFile('/etc/password', function(err, data){
// do something
fs.readFile('xxxx', function(err, data){
//do something
fs.readFile('xxxxx', function(err, data){
// do something
})
})
})
……
同樣,express也不例外,常常會讓你深陷回調地獄。通常一個api需要寫大量的代碼來完成,此時為了更好地開發和維護,你不得不每個api都單獨一個js文件來處理。
為了解決異步回調這個大問題,js生態出現了很多解決方案,
其中比較好的兩個——promise,async。
promise, async時代
首先說說async。
這曾是一個非常優秀的第三方模塊,它基於回調機制來實現,是處理異步回調很好的解決方案,如今github上已超兩萬多顆星。
async提供兩個非常好的處理異步的方法,分別是串行執行的waterfall,以及並行執行的parallel。
下面來個粟子:
# waterfall 按順序執行,執行完一個,傳遞給下一個,最終結果返回給最后的回調函數
async.waterfall([
function(callback){
callback(null, 'one', 'two');
},
function(arg1, arg2, callback){
// arg1 now equals 'one' and arg2 now equals 'two'
callback(null, 'three');
},
function(arg1, callback){
// arg1 now equals 'three'
callback(null, 'done');
}
], function (err, result) {
// result now equals 'done'
console.log(result);
});
# parallel 並行執行,即同時執行
async.parallel([
function(callback){
callback(null, 'one');
},
function(callback){
callback(null, 'two');
}
],
function(err, results){
// 最終處理
});
很顯然,這很大程度上避免了回調地獄,並且有一個完整的控制流,使你可以很好的組織代碼。
接下來說說promise
作為一名合格的前端,你有必要對promise有所了解,可以參考阮一峰寫的es6入門之promise。
首先,promise是es6的特性之一,實際是可用來傳遞異步操作流的對象。
promise提供三種狀態,Pending(進行中),Resolved(已解決),Rejected(已失敗)。
promise提供兩個方法,resolve()和reject(),可用於處理最終結果。
promise還提供一個then方法,用於異步處理過程,這是一個控制流方法,可以不停地執行下去,直到得到你想要的結果。
promise還提供了catch方法,用於捕獲和處理異步處理過程中出現的異常。
下面來舉個粟子:
var promise = new Promise(function(resolve, reject) {
// 一些異步邏輯,比如ajax, setTimeout等
if (/* 異步操作成功 */){
resolve(value); // 成功則返回結果
} else {
reject(error); // 失敗則返回錯誤
}
}).then(function(value){
// 不是想要的結果,繼續往下執行
}).then(function(value){
// 不是想要的結果,繼續往下執行
}).then
……
}).then(function(value){
// 是最終想要的結果
}).catch(function(err){
throw err; // 如果有異常則拋出
})
那么,能不能同時執行多個promise實例呢?
可以的,promise.all()方法可以幫到你。
不得不說,promise是解決異步回調的一大進步,是一個非常優秀的解決方案。而由於promise的強大,生態圈出現了很多基於promise的優秀模塊, 比如bluebird, q等等。
然而,promise並非終點,它只是弱化了回調地獄,並不能真正消除回調。使用promise仍然要處理很多復雜的邏輯,以及寫很多的邏輯代碼
而要消除回調,意味着要實現以同步的方式來寫異步編程。
那么如何來實現?
此時舞台再次交給TJ大神,因為他寫了個co,利用generator協程機制,實現以同步的方式來寫異步編程。
不得不膜拜下TJ大神。
generator 時代
關於generator的相關知識,可參考阮一峰老師寫的es6入門之generator。
和promise一樣,generator同樣是es6的新特性,但它並非為解決回調而存在的,只是它恰好擁有這個能力,而TJ大神看到這種可能,於是他利用generator封裝了co。並基於co,他又創造了個更輕量,性能更好的koa1web框架。
自此,koa1終於誕生了!它迎合了es6和co,
koa1和express相比,有非常大的進步,其中之一就是它很大程度上真正地解決了異步回調問題,真正意義上實現同步方式來寫異步編程。
再就是,koa1更輕量,性能比express更為優異。
koa1實現同步寫異步的關鍵點就是co。那么,co是如何實現同步寫異步的呢?
下面繼續來個舉個粟子:
# 正常的異步回調
var request = require('request');
var a = {};
var b = {};
request('http://www.google.com', function (error, response, body) {
if (!error && response.statusCode == 200) {
a.response = response;
a.body = body;
request('http://www.yahoo.com', function (error, response, body) {
if (!error && response.statusCode == 200) {
b.response = response;
b.body = body;
}
});
}
});
# co異步處理
co(function *(){
var a = yield request('http://google.com'); // 以同步的方式,直接拿到異步結果,並往下執行
var b = yield request('http://yahoo.com');
console.log(a[0].statusCode);
console.log(b[0].statusCode);
})()
看完這個粟子,是不是十分的激動呢?
我們再來看看,基於co的koa1是如何處理異步的, 同樣舉個粟子:
# 發布文章接口
const parse = require('co-body');
const mongoose = require('mongoose');
const Post = mongoose.model('Post');
// 發布文章
exports.create = function *() { // 使用 *表示這是一個gentator函數
let post = new Post(this.req.body.post);
let tags;
post.set('user_id', this.user.id);
if (yield post.save()) { // yield直接獲取異步執行的結果
this.redirect('/admin/posts');
} else {
tags = yield Tag.findAll();
this.body = yield render('post/new', { post: post.toJSON(), tags: tags.toJSON() }); // yield直接獲取異步執行的結果
}
}
想象一下,這個例子如果使用express來做會是怎樣呢?
相信你心中有數,很無情地拋棄了express,express哭暈廁所😢。
下面開始回歸正題。
我們來探討下,如何使用更好的組織結構,更少的代碼量來實現大量api的邏輯層
3, 探討一,koa1的實現
經過前面的諸多講述了解到,異步回調這一大難題,到了koa1才真正意義上的得以解決,准確來說是generator的功勞。
以同步的方式處理異步回調,這才是我們想要的結果,意味着我們可以用很少的代碼量來實現api的邏輯層。
解決了異步回調后,此時我們考慮另一個問題,如何集中處理,暴露大量api邏輯層?
此時,時代進步的利器——es6,排上用場了。
使用es6
這里主要使用es6的幾個新特效,export, import等等。
下面,我們舉個粟子來講述:
首先是api接口層
# /server/router.js // 組織api的接口層
const router = require('koa-router')(); // koa1.x
const userctrl = require('../controllers/users/userctrl'); // 引用用戶模塊邏輯層
const articlectrl = require('../controllers/articles/articlectrl'); // 引用文章模塊邏輯層
router
// 用戶模塊api
.post('/api/user/login',userctrl.login) // 用戶登錄
.post('/api/user/register',userctrl.register) // 用戶注冊
.put('/api/user/put',userctrl.put) // 更改用戶資料
.put('/api/user/resetpwd',userctrl.resetpwd) // 重置用戶密碼
.delete('/api/user/deluser',resetpwd.deluser) // 刪除用戶
// 文章模塊api
.post('/api/article/create',articlectrl.create) // 發布文章
.get('/api/article/detail',articlectrl.detail) // 獲取文章詳情
.put('/api/article/put',articlectrl.put) // 編輯文章
.delete('/api/article/del',articlectrl.del) // 刪除文章
.post('/api/article/praise',articlectrl.praise) // 點贊文章
.post('/api/article/comments',articlectrl.comments) // 發布評論
.delete('/api/article/del_comments',articlectrl.del_comments); // 刪除評論
export default router;
不知注意到沒,用戶模塊和文章模塊都分別只引入了一個文件,分別是userctrl.js和articlectrl.js,所有用戶和文章模塊相關的api邏輯層都集中在這兩個文件中處理。
如何做的呢? 請看下面的粟子:
- 用戶模塊
# /controllers/users/userctrl.js
// 用戶登錄
exports.login = function *(){
// yield ....
}
// 用戶注冊
exports.register = function *(){
// yield ....
}
// 更改用戶資料
exports.put = function *(){
// yield ....
}
// 重置用戶密碼
exports.resetpwd = function *(){
// yield ....
}
// 用戶登錄
exports.deluser = function *(){
// yield ....
}
- 文章模塊
# /controllers/articles/articlectrl.js
// 發布文章
exports.create = function *(){
// yield ....
}
// 獲取文章詳情
exports.detail = function *(){
// yield ....
}
// 編輯文章
exports.put = function *(){
// yield ....
}
// 刪除文章
exports.del = function *(){
// yield ....
}
// 點贊文章
exports.praise = function *(){
// yield ....
}
// 發布評論
exports.comments = function *(){
// yield ....
}
// 刪除評論
exports.del_comments = function *(){
// yield ....
}
到了這一步,api接口層和邏輯層都已處理完畢。
這里有個小問題,使用koa,意味着你需要使用try/catch去捕獲內部錯誤,但如果每個api都try/catch一遍,那是極其繁瑣的,
也會占用不少代碼量和空間
對於這個問題,我們可以把try/catch封裝成一個中間件來處理,只需要把這個中間放在路由之前執行即可。對此,可以參考阿里雲棲里的這篇文章——如何優雅的在 koa 中處理錯誤
至此,一個基於koa1+es6的Restful API打造完成。
然而,這仍不是終點。
4. co的末日,也是koa1的末日
co/koa1這么厲害,實現了promise,async都解決不了的同步寫異步,為什么會是末日呢?
co/koa1並不是不好,而是有比它更好的,從而淹沒了他們的光芒,所謂壯士一去不復返,垂淚三千尺😢。
是什么搶走了co/koa1的光芒?你應該猜到了,那就是koa2、async/await
async/await來勢洶洶,它有個代號,叫——終結者。別誤會,不是那個酷酷的美國大叔。
async/await並非第三方實現,而是原生javascript的實現,也就是說它不是bluebird,q,async那一流,將來它是要進入w3c標准的,官方的解決方案。 准確地說,它才是正統皇帝,generator只是代皇帝,bluebird,q,async之類的則只是江湖俠客。
為此,自nodejs發布到7.x以后,TJ 大神推出了koa2,內置co包,直接支持async/await。並將會在koa3中完全移除對generators的支持。
async/await非常新,它並不屬於es6,而是屬於es7。和generator一樣,它實現了同步寫異步,終結異步回調。
而async/await具有非常大的優勢,首先它本身是generator語法糖,自帶執行器,更具語義化,適用性更廣。其次,它並不需要像co這樣的第三方實現,而是原生支持的。
那么,使用async/await是怎樣的體驗呢?以我的開源博客sinn源碼為例,下面來個粟子:
// 查詢二級文章分類
static async get_category(ctx) { // async聲明這是一個async函數
const data = await CategoryModel.find(); // await 獲取異步結果
if(!data) return ctx.error({msg: '暫無數據'});
return ctx.success({ data });
}
// 查詢分類菜單
static async getmenu_category(ctx) {
const data = await CategoryModel.find({}).populate('cate_parent');
if(!data) return ctx.error({msg: '獲取菜單失敗!'});
return ctx.success({ data });
}
5. 探討二,koa2+es6/7的實現
直接奔入最終主題。
前面講了koa1+es6實現Restful API的打造,可它並非是最優解。
真正的最優方案是koa2+async/await+class的實現。
這里為什么提到class呢?
class是es6版的面向對象的實現,是的,你沒有看錯,你曾經所熟悉的oop可以玩起來了。
可是,這里為什么需要用到它?
因為,class+async/await的結合,可以使你更好的組織api的邏輯層,語義更清晰,結構更清晰,代碼量更少更輕,更容易維護。至此,你不再需要export每個接口邏輯了。另一個優點,它同樣具有很好的性能。
下面來個真實的粟子,以我的開源博客sinn源碼為例:
首先是接口層:
# /server/router.js // 組織api的接口層
const router = require('koa-router')();
const userctrl = require('../controllers/users/UserController'); // 引入用戶模塊邏輯層
router
// 用戶模塊api
.post('/api/user/login',userctrl.login) // 用戶登錄
.post('/api/user/register',userctrl.register) // 用戶注冊
.get('/api/user/logout',userctrl.logout) // 用戶退出
.put('/api/user/put',userctrl.put) // 更改用戶資料
.put('/api/user/resetpwd',userctrl.resetpwd) // 重置用戶密碼
.delete('/api/user/deluser',resetpwd.deluser) // 刪除用戶
……
然后是邏輯層
# /server/users/UserController.js 用戶模塊
import mongoose from 'mongoose';
import md5 from 'md5';
const UserModel = mongoose.model('User');
class UserController {
// 用戶注冊
async register(ctx) {
// await ……
}
// 用戶登錄
async login(ctx) {
// await ……
}
// 用戶退出
async logout(ctx) {
// await ……
}
// 更新用戶資料
async put(ctx) {
// await ……
}
// 刪除用戶
async deluser(ctx) {
// await ……
}
// 重置密碼
async resetpwd(ctx) {
// await ……
}
……
}
export default new UserController();
是不是更清晰,更有結構性了呢?
你甚至還可以用extends(繼承)來實現更復雜的api。
但是,不知你有沒有注意到一個細節,上面的例子用了new實例化。
實例化,意味着會消耗一定內存,消耗性能。雖然在后端這是種消耗不會很大。
但是作為一名優秀的程序員,我們盡量追求極致。
需要明白的一點是,通常我們的api不會復雜到大量使用oop的知識,比如大量地使用原型,繼承來實現復雜的實例,並沒有,至少后端js邏輯不會是前端那般復雜。
其次,我們的需求很簡單,只需要能夠批量定義和導出眾多api的邏輯層方法即可。
既然如此,為什么不用靜態方法呢?是的,static來了。
es6的class中,可用static來定義靜態方法,甚至可以定義靜態屬性(es7才實現)。靜態方法並不需要實例化就可以訪問,也就意味着,使用static,你不需要new,你可以減少內存的損耗。
下面我們改造一下上面邏輯層的例子:
class UserController {
// 用戶注冊
static async register(ctx) {
// await ……
}
// 用戶登錄
static async login(ctx) {
// await ……
}
// 用戶退出
static async logout(ctx) {
// await ……
}
// 更新用戶資料
static async put(ctx) {
// await ……
}
// 刪除用戶
static async deluser(ctx) {
// await ……
}
// 重置密碼
static async resetpwd(ctx) {
// await ……
}
……
export default UserController;
}
是不是感覺高大上了很多?
另外,還有兩點我們可以優化的。
第一點是,避免在每個接口邏輯層中使用try/catch,而是封裝一個try/catch中間件來處理它們,這樣可以減少代碼量,工作量,以及減少空間的占用。
第二點是,把一些公共方法抽離出來,同樣用class來組織它們,使用也很簡單,你可以單獨引進,也可以使用extends來繼承公共方法的class類,以訪問父類方法的方式來獲取它們。
至此,一個基於koa2+es6/7打造的高質量Restful API終於完成。
總結
如果你正准備學nodejs,除了原生node以外,你可以直接學習和使用koa2。
如果你習慣於express或koa1,也建議遷移到koa2
async/await以及眾多es6/7特性的出現,是對nodejs負擔的一種釋放,你可以很好地利用好它們來提高你的編碼效率和質量。
原文:https://zhuanlan.zhihu.com/p/26216336