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