Egg 編碼實戰 ---- 一個不斷加需求的 API 實現之旅

前言

感謝 Node.js 的誕生,讓前端工程師也能夠低成本地涉足後端的開發;而 Egg.js,更是極大地方便了開發者使用 Node.js 來開發一個 Restful 服務。html

現在,不少人會認爲,基於 Egg.js 開發一個 API 特別簡單,只須要按照規範實現 Controller,必要的時候實現 ServiceController 進行調用,而後在 Router 中將請求路徑、請求方法與 Controller 對應起來便可。但是,事情真的那麼簡單嗎?讓咱們跟隨小白一塊兒來經歷一個不斷加需求的 API 實現之旅。前端

特別說明:本文重點不在於一個 API 服務的完整搭建,請求認證過程在此不作贅述,參數合法性校驗也不詳細展開,請求的竟爭問題也暫時忽略mysql

小白的實現之旅

小白經過自主學習,學會使用 Egg 開發 HTTP 接口以後,躍躍欲試,並向主管代表,本身能夠接收一些 HTTP 服務的開發需求。主管爲了避免打擊小白的熱情,想了想,忽然有一個點子:小白,你來實現一個文本存儲的服務吧,咱們前端常常有些數據須要存放一下,不想每次都須要後端來支持。redis

需求分析

對於這個需求,小白進行了分析,並造成了如下用例:sql

  • 做爲 前端開發人員,我能夠 調用一個 API,傳入一個 code 和一串我想存儲的文本內容,以便 實現數據存儲
  • 做爲 前端開發人員,我能夠 調用一個 API,傳入一個 code,獲取到原先存儲的文本內容,以便 實現數據讀取

技術方案 V1.0

要把數據存到哪裏呢?小白有了個一個想法,既然在 Web 端有一個全局共享的 window 對象,那麼在 Egg 裏面有沒有呢?檢索了一遍文檔,發現typescript

Application 是全局應用對象,在一個應用中,只會實例化一個,它繼承自 Koa.Application,在它上面咱們能夠掛載一些全局的方法和對象。咱們能夠輕鬆的在插件或者應用中擴展 Application 對象。數據庫

所以,只須要在 Application 對象上面擴展一個 cache 對象,把傳進來的 code 做爲 key,文本做爲 value 存儲進去就能夠了,讀取的時候也十分方便。npm

代碼實現 V1.0

初始化

小白習慣使用 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 || '服務器錯誤'
    };
  }
};

複製代碼

Controller

建立 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);
    }
  }
}

複製代碼

Router

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 測試沒有問題,高高興興地去找主管交差。主管問了下實現思路,提出了一個問題:小白,這樣看上去是實現了需求,可是若是你的應用由於某種緣由重啓了一下,是否是數據就沒了?

技術方案 V2.0

小白撓了撓頭,反思了一下,確實本身沒有考慮到正式使用過程當中會產生的一些問題。想要存儲不丟失,那就必須有個地方存下這些數據。那就參考應用運行日誌,把數據記錄到文件中吧,使用原生的 fs 提供的 FileAPI 去作文件的讀寫。

但 cache 的方式我又不想要放棄,那是效率最高的,能夠用來存儲一些臨時數據,倒不如我就擴展一下剛剛的 API,支持多種存儲方式吧。Controller 中多接收一個參數 type,默認爲 file,把數據存儲和讀取方式改成經過文件,當用戶傳 cache 的時候,才使用內存讀寫的方式。

代碼實現 V2.0

Service

將經過文件讀取和存儲數據的方法封裝到 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];
  }
}

複製代碼

Controller

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:調用存儲的接口成功了,調用讀取的接口卻拿不到數據。

小白很快發現了是由於文件只存在單個應用上面而引起的問題,數據被分散存儲了,固然不行。因而去請教資深的小明大佬。小明聽到這個問題,笑了笑,表示本身曾經也踩過一樣的坑,多實例部署的存儲共享,建議使用數據庫或緩存來解決這個問題。

技術方案 V2.1

小白決定繼續從 2.0 的基礎上擴展,讓 API 支持更多的存儲模式。保留原有的方式,是由於這個服務單機部署也能夠實現某些需求,並且有時候 mysql 和 redis 這樣的存儲工具不必定會有。

代碼實現 V2.1

Service

實現兩個 service,封裝 mysql 和 redis 讀寫數據的方法,分別爲 app/service/mysql.tsapp/service/redis.ts,代碼較長就不展開

Controller

修改 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,並且咱們跟你的服務對接穩定運行一段時間了,比較放心」。

小白接受了,畢竟本身的服務被不少人使用,也是一種成就感嘛。

技術方案 V3.0

順着上面的思路,只須要添加一個 projectA.ts 做爲 Service,實現與項目 A 後端的通信,再在 Controller 判斷 type 去調用便可。

正着急動手之時,小白又想了想,萬一將來更多的系統有需求,我須要實現不一樣的存儲方式時,Controller 中的 switch(type) 部分就會愈來愈臃腫,本身是不太喜歡這種方式的。

技術方案 V3.1

小白沒有什麼好招,只好去去請教老司機小明。小明看了看代碼,微微一笑,留下一句「你能夠往 OCP 和 DIP 上作思考和嘗試」,深藏功與名地繼續忙去了。本着對小明的信賴,小白趕忙翻書找資料,複習了一遍 SOLID 設計原則。

OCP,開閉原則,指的是對擴展開放、對修改關閉。

小白分析了一下本身的程序:對於添加新的存儲方式,由於把不一樣的業務邏輯抽取到 Service 中,因此知足了對擴展開放的原則;可是,對於 Controller,它的職責應該是控制業務流程,而添加新的存儲方式並無對業務流程形成影響,其實不該該去修改到它的代碼,所以不知足對修改關閉的原則。

DIP,依賴倒置原則,上層模塊不該該依賴底層模塊,它們都應該依賴於抽象;抽象不該該依賴於細節,細節應該依賴於抽象

小白看了一下,在本身的程序中,Controller 屬於上層模塊,Service 屬於底層模塊,上層模塊直接依賴了底層模塊,因此當底層模塊變更或者擴展的時候,上層模塊也會被迫須要作一些調整,所以不知足依賴倒置原則。

爲了保持 Controller 的穩定,須要將全部的 Service 作一層抽象,讓 Controller 沒必要關心細節。還好,以前寫 Service 的時候,恰好把 saveget 方法定義好了,那麼 Controller 只須要知道這兩個方法便可,把細節隱藏。而在 TypeScript 裏面的作法,就是使用 interface

代碼實現 V3.1

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

接着是改造各個 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

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

最後,編寫穩定的 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,值得每一位工程師細細品味,不斷實踐。

相關文章
相關標籤/搜索