感謝 Node.js
的誕生,讓前端工程師也能夠低成本地涉足後端的開發;而 Egg.js,更是極大地方便了開發者使用 Node.js
來開發一個 Restful 服務。html
現在,不少人會認爲,基於 Egg.js
開發一個 API 特別簡單,只須要按照規範實現 Controller,必要的時候實現 Service 供 Controller
進行調用,而後在 Router 中將請求路徑、請求方法與 Controller
對應起來便可。但是,事情真的那麼簡單嗎?讓咱們跟隨小白一塊兒來經歷一個不斷加需求的 API 實現之旅。前端
特別說明:本文重點不在於一個 API 服務的完整搭建,請求認證過程在此不作贅述,參數合法性校驗也不詳細展開,請求的竟爭問題也暫時忽略mysql
小白經過自主學習,學會使用 Egg 開發 HTTP 接口以後,躍躍欲試,並向主管代表,本身能夠接收一些 HTTP 服務的開發需求。主管爲了避免打擊小白的熱情,想了想,忽然有一個點子:小白,你來實現一個文本存儲的服務吧,咱們前端常常有些數據須要存放一下,不想每次都須要後端來支持。redis
對於這個需求,小白進行了分析,並造成了如下用例:sql
要把數據存到哪裏呢?小白有了個一個想法,既然在 Web 端有一個全局共享的 window
對象,那麼在 Egg 裏面有沒有呢?檢索了一遍文檔,發現typescript
Application 是全局應用對象,在一個應用中,只會實例化一個,它繼承自 Koa.Application,在它上面咱們能夠掛載一些全局的方法和對象。咱們能夠輕鬆的在插件或者應用中擴展 Application 對象。數據庫
所以,只須要在 Application 對象上面擴展一個 cache 對象,把傳進來的 code 做爲 key,文本做爲 value 存儲進去就能夠了,讀取的時候也十分方便。npm
小白習慣使用 TypeScript 編碼,因而先使用 Egg官方文檔 提供的初始化命令進行項目初始化json
mkdir store && cd store npm init egg --type=ts npm i npm run dev 複製代碼
建立 app/extend/application.ts
文件,遵循官方寫法,在 ctx.app
對象上擴展 cache 屬性,並添加一些經常使用輔助方法後端
const cache: { [propName: string]: any; } = {}; export default { cache, // 組裝成功返回的數據格式 successResponse(data?) { return { result: 'success', data }; }, // 組裝錯誤返回的數據格式 errorResponse(error) { return { result: 'failed', errCode: typeof error === 'string' ? 500 : error.code || '500', message: typeof error === 'string' ? error : error.message || '服務器錯誤' }; } }; 複製代碼
建立 app/controller/store.ts
,實現 save 和 get 方法
import { Controller } from 'egg'; export default class extends Controller { public async save() { const { ctx } = this; const { key, value } = ctx.request.body; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { ctx.app.cache[key] = value; ctx.body = ctx.app.successResponse(); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } public async get() { const { ctx } = this; const { key } = ctx.query; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { ctx.body = ctx.app.successResponse(ctx.app.cache[key]); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } } 複製代碼
在 app/router.ts
中,添加路由
import { Application } from 'egg'; export default (app: Application) => { const { controller, router } = app; // demo 路由,此處可忽略 router.get('/', controller.home.index); // store router.post('/store', controller.store.save); router.get('/store', controller.store.get); }; 複製代碼
「大功告成,我還考慮了異常捕捉,通用函數抽取呢」小白內心對本身很滿意,用 PostMan 測試沒有問題,高高興興地去找主管交差。主管問了下實現思路,提出了一個問題:小白,這樣看上去是實現了需求,可是若是你的應用由於某種緣由重啓了一下,是否是數據就沒了?
小白撓了撓頭,反思了一下,確實本身沒有考慮到正式使用過程當中會產生的一些問題。想要存儲不丟失,那就必須有個地方存下這些數據。那就參考應用運行日誌,把數據記錄到文件中吧,使用原生的 fs
提供的 FileAPI 去作文件的讀寫。
但 cache 的方式我又不想要放棄,那是效率最高的,能夠用來存儲一些臨時數據,倒不如我就擴展一下剛剛的 API,支持多種存儲方式吧。Controller
中多接收一個參數 type
,默認爲 file,把數據存儲和讀取方式改成經過文件,當用戶傳 cache 的時候,才使用內存讀寫的方式。
將經過文件讀取和存儲數據的方法封裝到 app/service/file.ts
中
import { Service } from 'egg'; import * as fs from 'fs'; import * as path from 'path'; export default class extends Service { // 文件存儲路徑 private FILE_PATH = './app/file/cache.js'; public writeFile(filePath, fileData) { return new Promise((resolve, reject) => { const writeStream = fs.createWriteStream(filePath); writeStream.on('open', () => { const blockSize = 128; const nbBlocks = Math.ceil(fileData.length / blockSize); for (let i = 0; i < nbBlocks; i += 1) { const currentBlock = fileData.slice( blockSize * i, Math.min(blockSize * (i + 1), fileData.length) ); writeStream.write(currentBlock); } writeStream.end(); }); writeStream.on('error', err => { reject(err); }); writeStream.on('finish', () => { resolve(true); }); }); } public readFile(filePath): Promise<string> { return new Promise((resolve, reject) => { const readStream = fs.createReadStream(filePath); let data = ''; readStream.on('data', chunk => { data += chunk; }); readStream.on('end', () => { resolve(data ? data.toString() : JSON.stringify({})); }); readStream.on('error', err => { reject(err); }); }); } public async save(key: string, value) { const data: string = await this.readFile(path.resolve(this.FILE_PATH)); const jsonData = JSON.parse(data); jsonData[key] = value; await this.writeFile( path.resolve(this.FILE_PATH), new Buffer(JSON.stringify(jsonData)) ); return true; } public async get(key: string) { const data: string = await this.readFile(path.resolve(this.FILE_PATH)); const jsonData = JSON.parse(data); return jsonData[key]; } } 複製代碼
app/controller/store.ts
經過判斷 type,來調用不一樣的實現方式
import { Controller } from 'egg'; export default class extends Controller { public async save() { const { ctx } = this; const { key, value, type = 'file' } = ctx.request.body; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { switch (type) { case 'file': await ctx.service.file.save(key, value); break; default: ctx.app.cache[key] = value; break; } ctx.body = ctx.app.successResponse(); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } public async get() { const { ctx } = this; const { key, type = 'file' } = ctx.query; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { let data; switch (type) { case 'file': data = await ctx.service.file.get(key); break; default: data = ctx.app.cache[key]; break; } ctx.body = ctx.app.successResponse(data); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } } 複製代碼
「考慮了兩種模式的兼容,又能實現需求,很棒」小白內心美滋滋,繼續找主管驗收。主管看了一下,點點頭,吩咐小白部署上線並在項目組內部推廣使用。你們用了這個服務以後都挺舒服的,小白也挺有成就感。
但是好景不長,隨着使用人數的增長,小白慢慢收到一些反饋,「請求速度愈來愈慢了,有點痛苦」。小白便去諮詢後臺同窗怎麼辦,後臺同窗說最快的方法就是加實例,作個負載均衡。
小白趕忙行動,在另外一臺機器上也部署了一樣的應用,並請運維同窗幫忙作了個負載均衡,覺得能解決問題,卻沒想到引起了另一個大 BUG:調用存儲的接口成功了,調用讀取的接口卻拿不到數據。
小白很快發現了是由於文件只存在單個應用上面而引起的問題,數據被分散存儲了,固然不行。因而去請教資深的小明大佬。小明聽到這個問題,笑了笑,表示本身曾經也踩過一樣的坑,多實例部署的存儲共享,建議使用數據庫或緩存來解決這個問題。
小白決定繼續從 2.0 的基礎上擴展,讓 API 支持更多的存儲模式。保留原有的方式,是由於這個服務單機部署也能夠實現某些需求,並且有時候 mysql 和 redis 這樣的存儲工具不必定會有。
實現兩個 service,封裝 mysql 和 redis 讀寫數據的方法,分別爲 app/service/mysql.ts
和 app/service/redis.ts
,代碼較長就不展開
修改 app/controller/store.ts
,增長類型判斷。同時爲了不你們須要去修改原有的代碼,故默認類型要改成比較通用的 mysql
import { Controller } from 'egg'; export default class extends Controller { public async save() { const { ctx } = this; const { key, value, type = 'mysql' } = ctx.request.body; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { switch (type) { case 'file': await ctx.service.file.save(key, value); break; case 'mysql': await ctx.service.mysql.save(key, value); break; case 'redis': await ctx.service.redis.save(key, value); break; default: ctx.app.cache[key] = value; break; } ctx.body = ctx.app.successResponse(); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } public async get() { const { ctx } = this; const { key, type = 'cache' } = ctx.query; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { let data; switch (type) { case 'file': data = await ctx.service.file.get(key); break; case 'mysql': data = await ctx.service.mysql.get(key); break; case 'redis': data = await ctx.service.redis.get(key); break; default: data = ctx.app.cache[key]; break; } ctx.body = ctx.app.successResponse(data); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } } 複製代碼
小白將 V2.1 部署上線,成功解決了你們的問題,在業務高峯期擴展實例也變得比較方便了。
一段時間事後,小白收到了一個特別的需求:有個項目,想調用這個接口,將數據轉存到他們後端那邊,同時,獲取的時候也從後端那邊獲取。
小白就納悶了:爲何大家不直接對接後端,要通過我這裏中轉?「由於咱們後端沒有外網 API,並且咱們跟你的服務對接穩定運行一段時間了,比較放心」。
小白接受了,畢竟本身的服務被不少人使用,也是一種成就感嘛。
順着上面的思路,只須要添加一個 projectA.ts 做爲 Service,實現與項目 A 後端的通信,再在 Controller 判斷 type 去調用便可。
正着急動手之時,小白又想了想,萬一將來更多的系統有需求,我須要實現不一樣的存儲方式時,Controller 中的 switch(type)
部分就會愈來愈臃腫,本身是不太喜歡這種方式的。
小白沒有什麼好招,只好去去請教老司機小明。小明看了看代碼,微微一笑,留下一句「你能夠往 OCP 和 DIP 上作思考和嘗試」,深藏功與名地繼續忙去了。本着對小明的信賴,小白趕忙翻書找資料,複習了一遍 SOLID 設計原則。
OCP,開閉原則,指的是對擴展開放、對修改關閉。
小白分析了一下本身的程序:對於添加新的存儲方式,由於把不一樣的業務邏輯抽取到 Service 中,因此知足了對擴展開放的原則;可是,對於 Controller,它的職責應該是控制業務流程,而添加新的存儲方式並無對業務流程形成影響,其實不該該去修改到它的代碼,所以不知足對修改關閉的原則。
DIP,依賴倒置原則,上層模塊不該該依賴底層模塊,它們都應該依賴於抽象;抽象不該該依賴於細節,細節應該依賴於抽象
小白看了一下,在本身的程序中,Controller 屬於上層模塊,Service 屬於底層模塊,上層模塊直接依賴了底層模塊,因此當底層模塊變更或者擴展的時候,上層模塊也會被迫須要作一些調整,所以不知足依賴倒置原則。
爲了保持 Controller 的穩定,須要將全部的 Service 作一層抽象,讓 Controller 沒必要關心細節。還好,以前寫 Service 的時候,恰好把 save
和 get
方法定義好了,那麼 Controller 只須要知道這兩個方法便可,把細節隱藏。而在 TypeScript 裏面的作法,就是使用 interface
把原先的 /service/file.ts
等文件,移動到 /service/store/
下,把原先在 Controller 中實現的 cache 存取邏輯,抽象爲 /service/store/cache.ts
, 並新建 /service/store/interface.ts
文件,用於編寫 interface
export interface IStore { save: (key: string, value: string) => Promise<Boolean>; get: (key: string) => Promise<String>; } 複製代碼
接着是改造各個 Service 來 實現這個 interface。這裏以 /service/store/cache.ts
爲例,代碼以下
import { Service } from 'egg'; import { IStore } from './interface'; export default class extends Service implements IStore { public async save(key: string, value) { const { ctx } = this; ctx.app.cache[key] = value; return true; } public async get(key: string) { const { ctx } = this; return ctx.app.cache[key]; } } 複製代碼
接着是改造 Controller,爲了保持邏輯的穩定,咱們但願 Controller 不依賴具體的 Service,而只須要知道調用 Service 中的方法來實現流程。故咱們先擴展一下 Application 對象, 提供一個判斷具體場景,返回具體 Service 的方法,讓 Controller 去使用。
此處應該去擴展 Egg 的 Helper 對象,爲了篇幅,此處直接擴展 Application
修改 app/extend/application.ts
文件
import { IStore } from '../service/store/interface'; import { Context } from 'egg'; const cache: { [propName: string]: any; } = {}; export default { cache, successResponse(data?) { // ... }, errorResponse(error) { // ... }, // 根據具體參數,返回 StoreService 的具體實現 getStoreService(ctx: Context): IStore { return ctx.service.store[ ctx.query.type || ctx.request.body.type || 'cache' ]; } }; 複製代碼
最後,編寫穩定的 Controller 代碼
import { Controller } from 'egg'; export default class extends Controller { public async save() { const { ctx } = this; const { key, value } = ctx.request.body; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { await ctx.app.getStoreService(ctx).save(key, value); ctx.body = ctx.app.successResponse(); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } public async get() { const { ctx } = this; const { key } = ctx.query; if (!key) { return (ctx.body = ctx.app.errorResponse('請提供 key 參數')); } try { const data = await ctx.app.getStoreService(ctx).get(key); ctx.body = ctx.app.successResponse(data); } catch (error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); } } } 複製代碼
當將來須要擴展時,只要業務流程不變,僅須要在 Service 中添加文件並實現 IStore
接口便可,真正作到了加需求時只修改一個地方。
小白十分滿意地拿出做品給小明看,小明微笑地問:「你知道本身在其中使用了什麼設計模式嗎」
小白愣了一下,本身並無想到這一層,只是遵守 設計原則
編碼而已,又仔細看了看,露出了笑容:「原來如此,我在不知不覺中用了 XX 模式啊,至因而什麼模式,我不告訴你,你本身細品」
今後,小白踏上了實踐設計原則的打怪升級之路。
在這篇文章中,咱們跟隨小白,一塊兒從零實現了一個簡單的存儲服務,而且在需求不斷升級的過程當中,對咱們的代碼進行迭代,最後造成比較穩定的架構,符合 OCP 和 DIP,讓擴展變得更加靈活,又保證原有業務邏輯的穩定。
在任什麼時候候,設計原則
都是編寫代碼、設計架構比不可少的指導方針,而 設計模式
是設計原則在不一樣場景下的具體實現,咱們要注重的是 道
而不是 術
。
SOLID,值得每一位工程師細細品味,不斷實踐。