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,值得每一位工程师细细品味,不断实践。