如何使用koa2+es6/7打造高質量Restful API

0. 前言

如今nodejs變得越來越火熱,採用nodejs實現前後端分離架構已被多數大公司所採用。

在過去,使用nodejs大家首先想到的是TJ大神寫的express.js,而發展到如今,更輕量,性能更好的koa已然成為主流,

它同樣出自TJ大神手筆,如今版本已更新到了koa2,不僅性能優異,它還支持async/await,堪稱回調地獄的終結者

下面,我們來探討下,如何使用koa2+es6/7來打造高質量的Restful風格API。

刨根問底,篇幅略長,精華在後面,需要耐心看。

1. 兩種模式

一種是耦合模式,即介面層和邏輯層都由一個函數來處理完成。

另一種是分離模式,即介面層和邏輯層是分開的。

下面我們先來說第一種。

耦合模式

先舉個粟子,以express為例:

# /server/user/login.js 用戶登錄nnconst express = require(express);nconst router = express.Router();nn router.post(/api/user/login,function(req,res){nn // 邏輯層nn })nn# /server/user/register.js 用戶註冊nnconst express = require(express);nconst router = express.Router();nn router.post(/api/user/register,function(req,res){nn // 邏輯層nn })nn# /server/user/put.js 更改用戶資料nnconst express = require(express);nconst router = express.Router();nn router.post(/api/user/put,function(req,res){nn // 邏輯層nn })n

這種在過去很常見,相信很多人都寫過,我也不例外。但並不推薦。

首先,一個應用的api通常會很多,如果應用夠複雜,那意味著你的api可能要處理非常多的邏輯。

而為了應付這些需求,你不得不建立很多的文件,甚至困擾於如何劃分和組織好這些文件。

其次,後期並不好維護,當api過多,過於繁雜時,文件深層嵌套,也許你找一個api文件都費神費力。

分離模式

同樣先來個粟子:

# /server/router.jsnnconst express = require(express);nconst router = express.Router();nn router.post(/api/user/login,require(../controllers/users/login)) // 用戶登錄nn .post(/api/user/register,require(../controllers/users/register)) // 用戶註冊 nn .put(/api/user/put,require(../controllers/users/put) // 更改用戶資料nn .delete(/api/user/deluser,require(../controllers/users/deluser)) // 刪除用戶n ……n

很顯然,這種api已將介面層和邏輯層分離了,介面層由一個router.js文件來統一定義,而每個介面的邏輯層則由單獨的文件來處理,並按不同功能模塊用不同文件夾來組織這些邏輯文件。

那麼,這樣做有什麼好處呢?

首先,很直觀,整個結構很清晰,一目了然

其次,只需要你專註於處理邏輯

再者,api集中在router.js文件定義,同事更容易看懂你的代碼結構,或者知道你增改了哪些api等等,這很方便於多人協同開發,在大型開發中尤為重要

很顯然,分離模式優於耦合模式。

2. 如何更好地組織邏輯層

經過上面的分析之後,我們選擇更優的分離模式, 它只需要你關注邏輯層。

但是,以上面分離模式的例子為例,每一個介面仍然需要單獨一個js文件來處理它的邏輯層,並且需要用很多不同文件夾來組織它

們,假如應用足夠大,有幾十甚至上百個api,那意味著很有可能你的js邏輯文件也達幾十乃至上百個,而用來劃分和組織這些js文

件的文件夾也不在少數。

這就造成了過於臃腫,難以維護的毛病。

那麼,有沒有可能,一個功能模塊只需要一個js文件來處理它們的所有邏輯層,並更具可維護性呢?

打個比方,現在有一個博客站點,我僅使用一個user.js文件來處理用戶模塊所有api的邏輯層,包括註冊,登錄,修改,刪除,密碼重置等等,另外用一個article.js文件來處理文章模塊所有api的邏輯層,包括發布,修改,獲取詳情,點贊,評論,刪除等等。

如果可以做到這樣,那就意味著代碼量大大減少,且可維護性更高。

而要做到這步,我們需要解決兩個問題,一個是非同步回調,因為非同步回調使我們增加了很多代碼量,邏輯複雜,二是如何批量定義和導出大量api的邏輯層方法。

首先,我們先來解決非同步回調這個問題,下面將會展開講解。

為了減少篇幅,下面只做簡要的淺析。

express 時代

我們先來回顧一下歷史。

鑒於nodejs的回調機制,很多非同步操作都需要回調來完成,如果你的邏輯足夠複雜,很可能就會陷進回調地獄,下面是一個簡單的例子:

……nfs.readFile(/etc/password, function(err, data){n // do somethingn fs.readFile(xxxx, function(err, data){n //do somethingn fs.readFile(xxxxx, function(err, data){n // do somethingn })n })n})n……n

同樣,express也不例外,常常會讓你深陷回調地獄。通常一個api需要寫大量的代碼來完成,此時為了更好地開發和維護,你不得不每個api都單獨一個js文件來處理。

為了解決非同步回調這個大問題,js生態出現了很多解決方案,

其中比較好的兩個——promise,async。

promise, async時代

首先說說async。

這曾是一個非常優秀的第三方模塊,它基於回調機制來實現,是處理非同步回調很好的解決方案,如今github上已超兩萬多顆星。

async提供兩個非常好的處理非同步的方法,分別是串列執行的waterfall,以及並行執行的parallel。

下面來個粟子:

# waterfall 按順序執行,執行完一個,傳遞給下一個,最終結果返回給最後的回調函數nnasync.waterfall([n function(callback){n callback(null, one, two);n },n function(arg1, arg2, callback){n // arg1 now equals one and arg2 now equals twon callback(null, three);n },n function(arg1, callback){n // arg1 now equals threen callback(null, done);n }n], function (err, result) {n // result now equals donen console.log(result);n});nn# parallel 並行執行,即同時執行nnasync.parallel([n function(callback){n callback(null, one);n },n function(callback){n callback(null, two);n }n],nfunction(err, results){n // 最終處理n});n

很顯然,這很大程度上避免了回調地獄,並且有一個完整的控制流,使你可以很好的組織代碼。

接下來說說promise

作為一名合格的前端,你有必要對promise有所了解,可以參考阮一峰寫的es6入門之promise。

首先,promise是es6的特性之一,實際是可用來傳遞非同步操作流的對象。

promise提供三種狀態,Pending(進行中),Resolved(已解決),Rejected(已失敗)。

promise提供兩個方法,resolve()和reject(),可用於處理最終結果。

promise還提供一個then方法,用於非同步處理過程,這是一個控制流方法,可以不停地執行下去,直到得到你想要的結果。

promise還提供了catch方法,用於捕獲和處理非同步處理過程中出現的異常。

下面來舉個粟子:

var promise = new Promise(function(resolve, reject) {nn // 一些非同步邏輯,比如ajax, setTimeout等nn if (/* 非同步操作成功 */){n resolve(value); // 成功則返回結果n } else {n reject(error); // 失敗則返回錯誤n }n}).then(function(value){n // 不是想要的結果,繼續往下執行n}).then(function(value){n // 不是想要的結果,繼續往下執行n}).thenn……nn}).then(function(value){n // 是最終想要的結果n}).catch(function(err){nn throw err; // 如果有異常則拋出nn})n

那麼,能不能同時執行多個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是如何實現同步寫非同步的呢?

下面繼續來個舉個粟子:

# 正常的非同步回調nvar request = require(request);nvar a = {};nvar b = {};nrequest(http://www.google.com, function (error, response, body) {n if (!error && response.statusCode == 200) {n a.response = response;n a.body = body;n request(http://www.yahoo.com, function (error, response, body) {n if (!error && response.statusCode == 200) {n b.response = response;n b.body = body;n }n });n }n});nnn# co非同步處理nnco(function *(){n var a = yield request(http://google.com); // 以同步的方式,直接拿到非同步結果,並往下執行n var b = yield request(http://yahoo.com);n console.log(a[0].statusCode);n console.log(b[0].statusCode);n})()n

看完這個粟子,是不是十分的激動呢?

我們再來看看,基於co的koa1是如何處理非同步的, 同樣舉個粟子:

# 發布文章介面nnconst parse = require(co-body);nconst mongoose = require(mongoose);nconst Post = mongoose.model(Post);nn// 發布文章nexports.create = function *() { // 使用 *表示這是一個gentator函數n let post = new Post(this.req.body.post);n let tags;nn post.set(user_id, this.user.id);nn if (yield post.save()) { // yield直接獲取非同步執行的結果n this.redirect(/admin/posts);n } else {n tags = yield Tag.findAll();n this.body = yield render(post/new, { post: post.toJSON(), tags: tags.toJSON() }); // yield直接獲取非同步執行的結果n }n}n

想像一下,這個例子如果使用express來做會是怎樣呢?

相信你心中有數,很無情地拋棄了express,express哭暈廁所??。

下面開始回歸正題。

我們來探討下,如何使用更好的組織結構,更少的代碼量來實現大量api的邏輯層

3, 探討一,koa1的實現

經過前面的諸多講述了解到,非同步回調這一大難題,到了koa1才真正意義上的得以解決,準確來說是generator的功勞。

以同步的方式處理非同步回調,這才是我們想要的結果,意味著我們可以用很少的代碼量來實現api的邏輯層。

解決了非同步回調後,此時我們考慮另一個問題,如何集中處理,暴露大量api邏輯層?

此時,時代進步的利器——es6,排上用場了。

使用es6

這裡主要使用es6的幾個新特效,export, import等等。

下面,我們舉個粟子來講述:

首先是api介面層

# /server/router.js // 組織api的介面層nn const router = require(koa-router)(); // koa1.xn const userctrl = require(../controllers/users/userctrl); // 引用用戶模塊邏輯層n const articlectrl = require(../controllers/articles/articlectrl); // 引用文章模塊邏輯層nn routern // 用戶模塊apin .post(/api/user/login,userctrl.login) // 用戶登錄nn .post(/api/user/register,userctrl.register) // 用戶註冊 nn .put(/api/user/put,userctrl.put) // 更改用戶資料nn .put(/api/user/resetpwd,userctrl.resetpwd) // 重置用戶密碼nn .delete(/api/user/deluser,resetpwd.deluser) // 刪除用戶nn // 文章模塊apin .post(/api/article/create,articlectrl.create) // 發布文章nn .get(/api/article/detail,articlectrl.detail) // 獲取文章詳情 nn .put(/api/article/put,articlectrl.put) // 編輯文章nn .delete(/api/article/del,articlectrl.del) // 刪除文章nn .post(/api/article/praise,articlectrl.praise) // 點贊文章nn .post(/api/article/comments,articlectrl.comments) // 發布評論nn .delete(/api/article/del_comments,articlectrl.del_comments); // 刪除評論nn export default router;n

不知注意到沒,用戶模塊和文章模塊都分別只引入了一個文件,分別是userctrl.js和articlectrl.js,所有用戶和文章模塊相關的api邏輯層都集中在這兩個文件中處理。

如何做的呢? 請看下面的粟子:

  • 用戶模塊

# /controllers/users/userctrl.jsnn// 用戶登錄nexports.login = function *(){n // yield ....n}nn// 用戶註冊nexports.register = function *(){n // yield ....n}nn// 更改用戶資料nexports.put = function *(){n // yield ....n}nn// 重置用戶密碼nexports.resetpwd = function *(){n // yield ....n}nn// 用戶登錄nexports.deluser = function *(){n // yield ....n}n

  • 文章模塊

# /controllers/articles/articlectrl.jsnn// 發布文章nexports.create = function *(){n // yield ....n}nn// 獲取文章詳情nexports.detail = function *(){n // yield ....n}nn// 編輯文章nexports.put = function *(){n // yield ....n}nn// 刪除文章nexports.del = function *(){n // yield ....n}nn// 點贊文章nexports.praise = function *(){n // yield ....n}nn// 發布評論nexports.comments = function *(){n // yield ....n}nn// 刪除評論nexports.del_comments = function *(){n // yield ....n}n

到了這一步,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源碼為例,下面來個粟子:

// 查詢二級文章分類n static async get_category(ctx) { // async聲明這是一個async函數n const data = await CategoryModel.find(); // await 獲取非同步結果n if(!data) return ctx.error({msg: 暫無數據});n return ctx.success({ data });n }nn // 查詢分類菜單n static async getmenu_category(ctx) {n const data = await CategoryModel.find({}).populate(cate_parent);n if(!data) return ctx.error({msg: 獲取菜單失敗!});n return ctx.success({ data });n }n

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的介面層nn const router = require(koa-router)(); n const userctrl = require(../controllers/users/UserController); // 引入用戶模塊邏輯層nn routern // 用戶模塊apin .post(/api/user/login,userctrl.login) // 用戶登錄n .post(/api/user/register,userctrl.register) // 用戶註冊 n .get(/api/user/logout,userctrl.logout) // 用戶退出 n .put(/api/user/put,userctrl.put) // 更改用戶資料n .put(/api/user/resetpwd,userctrl.resetpwd) // 重置用戶密碼n .delete(/api/user/deluser,resetpwd.deluser) // 刪除用戶n ……n

然後是邏輯層

# /server/users/UserController.js 用戶模塊 nnimport mongoose from mongoose;nimport md5 from md5;nconst UserModel = mongoose.model(User);nnclass UserController {nn // 用戶註冊n async register(ctx) {n // await ……n }nn // 用戶登錄n async login(ctx) {n // await ……n }nn // 用戶退出n async logout(ctx) {n // await ……n }nn // 更新用戶資料n async put(ctx) {n // await ……n }nn // 刪除用戶n async deluser(ctx) {n // await ……n }nn // 重置密碼n async resetpwd(ctx) {n // await ……n }nn ……nn}nexport default new UserController();n

是不是更清晰,更有結構性了呢?

你甚至還可以用extends(繼承)來實現更複雜的api。

但是,不知你有沒有注意到一個細節,上面的例子用了new實例化。

實例化,意味著會消耗一定內存,消耗性能。雖然在後端這是種消耗不會很大。

但是作為一名優秀的程序員,我們盡量追求極致。

需要明白的一點是,通常我們的api不會複雜到大量使用oop的知識,比如大量地使用原型,繼承來實現複雜的實例,並沒有,至少後端js邏輯不會是前端那般複雜。

其次,我們的需求很簡單,只需要能夠批量定義和導出眾多api的邏輯層方法即可。

既然如此,為什麼不用靜態方法呢?是的,static來了。

es6的class中,可用static來定義靜態方法,甚至可以定義靜態屬性(es7才實現)。靜態方法並不需要實例化就可以訪問,也就意味著,使用static,你不需要new,你可以減少內存的損耗。

下面我們改造一下上面邏輯層的例子:

class UserController {nn // 用戶註冊n static async register(ctx) {n // await ……n }nn // 用戶登錄n static async login(ctx) {n // await ……n }nn // 用戶退出n static async logout(ctx) {n // await ……n }nn // 更新用戶資料n static async put(ctx) {n // await ……n }nn // 刪除用戶n static async deluser(ctx) {n // await ……n }nn // 重置密碼n static async resetpwd(ctx) {n // await ……n }nn ……nnexport default UserController;nn}n

是不是感覺高大上了很多?

另外,還有兩點我們可以優化的。

第一點是,避免在每個介面邏輯層中使用try/catch,而是封裝一個try/catch中間件來處理它們,這樣可以減少代碼量,工作量,以及減少空間的佔用。

第二點是,把一些公共方法抽離出來,同樣用class來組織它們,使用也很簡單,你可以單獨引進,也可以使用extends來繼承公共方法的class類,以訪問父類方法的方式來獲取它們。

至此,一個基於koa2+es6/7打造的高質量Restful API終於完成。

總結

如果你正準備學nodejs,除了原生node以外,你可以直接學習和使用koa2。

如果你習慣於express或koa1,也建議遷移到koa2

async/await以及眾多es6/7特性的出現,是對nodejs負擔的一種釋放,你可以很好地利用好它們來提高你的編碼效率和質量。

本文首發於我的個人站點:SInn-小客棧-程序員的小黑屋

具體實踐案例可參考我的開源博客sinn的後端sinn-server。


推薦閱讀:

Nodejs系列課程,從入門到進階幫你打通全棧
Node快閃:發布你自己的npm模塊
基於 Node.js 的聲明式可監控爬蟲網路
Node.js 性能調優之調試篇(三)——三款實用調試工具

TAG:Nodejs | koa | 全栈开发 |