中间件式框架简单实现

February 29, 2020 · View on GitHub

前言

熟悉Node.js WEB开发的小伙伴肯定知道,在Node.js世界里,基本所有WEB开发的框架都是基于中间件形式架构。中间件架构大部分以HTTP的生命周期切面来提供中间件的处理,既能在任何开发语言低成本实现,又能符合开发者操作思维,方便生态的扩展。

综上所诉,实现一个DenoWEB框架,中间件式的框架是比较低成本和大众高接受度,本篇将基于Koa.js的洋葱模型中间件机制,集合上一章节里的 5.8 原生Deno处理HTTP请求5.9 原生Deno处理HTTP响应5.10 原生Deno实现稳定HTTP服务 打造一个健壮的 DenoKoa.js

实现过程

  • 提供一个可让中间件安全操作的HTTP上下文(Context)
    • Request安全操作,限制中间件只能去取请求行、请求头和请求体三种数据
    • Response安全操作
      • 限制中间件只能设置响应状态、响应头和响应体操作
      • 同时加上设置响应结束保护处理,防止响应设置提前结束,剩余中间件操作对响应数据的影响
    • 提供中间件数据通信方法
      • 数据通信的读、写、判断和清空处理
  • 利用 5.10 原生Deno实现稳定HTTP服务 的服务,创建一个HTTP安全上下文SafeContext
  • SafeContext 传入洋葱模型的处理中间件机制compose
  • 中间件结束和异常处理

具体实现

中间件内安全操作Context

源码地址

https://github.com/chenshenhai/deno_note/blob/master/demo/web/context.ts

源码讲解

./demo/web/context.ts

import { Context } from "./../server/context.ts";
import { Request, ReqGeneral } from "./../request/mod.ts";
import { Response } from "./../response/mod.ts";

/**
 * 封装安全的HTTP请求操作类
 * 只开放 getGeneral()、getHeaders()和getBodyStream 这三个方法
 */
class SafeRequest {
  private _req: Request;
  constructor(req: Request) {
    this._req = req;
  }

  async getGeneral(): Promise<ReqGeneral> {
    return await this._req.getGeneral();
  }

  async getHeaders(): Promise<Headers> {
    return await this._req.getHeaders();
  }

  async getBodyStream(): Promise<Uint8Array> {
    return await this._req.getBodyStream();
  }
}

/**
 * 封装安全的HTTP响应操作类
 * 重新封装 4个原有Response的方法
 * 保证响应结束后,禁止进行响应数据的设置,或者写操作
 *  setHeader(key: string, val: string): boolean;
 *  getHeaders(): Headers;
 *  setStatus(code: number): boolean;
 *  getStatus(): number;
 *  setBody(body: string): boolean;
 *  getBody(): string;
 * 添加判断是否响应结束的方法
 *  isFinish(): boolean
 * 添加设置响应结束的方法
 *  setFinish(): void
 */
class SafeResponse {
  private _res: Response;
  private _isFinish: boolean = false;
  constructor(res: Response) {
    this._res = res;
  }

  setHeader(key: string, val: string): boolean {
    if (this.isFinish() === true) {
      // 响应结束了,不再进行设置操作
      return false;
    }
    return this._res.setHeader(key, val);
  }
  getHeaders(): Headers {
    return this._res.getHeaders();
  }
  setStatus(status: number) {
    if (this.isFinish() === true) {
      // 响应结束了,不再进行设置操作
      return false;
    }
    return this._res.setStatus(status);
  }
  getStatus(): number {
    return this._res.getStatus();
  }
  setBody(body: string) {
    if (this.isFinish() === true) {
      // 响应结束了,不再进行设置操作
      return false;
    }
    return this._res.setBody(body);
  }
  getBody(): string {
    return this._res.getBody();
  }

  async flush(): Promise<number> {
    if (this.isFinish() === true) {
      // 响应结束了,不再进行TCP对象写操作
      return -1;
    }
    return await this._res.flush();
  }

  isFinish() {
    return this._isFinish;
  }
  setFinish() {
    this._isFinish = true;
  }
}


/**
 * 封装安全的HTTP上下文操作类
 * 基于 原有的 Context
 *  加工原有的 Context.req 成为可安全请求操作对象 SafeRequest
 *  加工原有的 Context.res 成为可安全响应操作对象 SafeResponse
 * 添加上下文缓存数据的能力,保证在中间件里可以进行数据通信
 * setData(key: string, val: any)
 * getData(key: string): string
 * cleanData()
 * hasData(key: string): boolean
 * deleteData(key: string)
 */
class SafeContext {
  private _ctx: Context;
  private _dataMap: {[key: string]: any} = {};
  public req: SafeRequest;
  public res: SafeResponse;
  constructor(ctx: Context) {
    this._ctx = ctx;
    this.req = new SafeRequest(ctx.req);
    this.res = new SafeResponse(ctx.res);
  }

  public setData(key: string, val: any) {
    if (!this._dataMap) {
      this._dataMap = {};
    }
    this._dataMap[key] = val;
  }

  public getData(key: string): string {
    const dataMap = this._dataMap || {};
    const val = dataMap[key];
    return val;
  }

  public cleanData() {
    this._dataMap = {};
  }

  public hasData(key: string): boolean {
    const dataMap = this._dataMap || {};
    return dataMap.hasOwnProperty(key);
  }

  public deleteData(key: string) {
    if (this._dataMap) {
      delete this._dataMap[key];
    }
  }
}

export { Context, SafeContext }

利用Koa.js洋葱模型

源码地址

https://github.com/chenshenhai/deno_note/blob/master/demo/web/compose.ts

洋葱模型讲解

本篇是实现一个Deno 版的Koa.js,因此就需要知道Koa.js框架洋葱模型的中间件机制原理。Koa.js的洋葱模型是基于koa-composenpm模块来实现的。我之前已在 Koa.js 设计模式-学习笔记-中间件引擎 详细讲解了,如果需要了解,可点击进入观看。

封装框架

源码地址

https://github.com/chenshenhai/deno_note/blob/master/demo/web/application.ts

源码讲解

./demo/web/application.ts

import { Context, SafeContext } from "./context.ts";
import { Server } from "./../server/mod.ts";
import { compose } from "./compose.ts";

const exit = Deno.exit;

class Application {

  private _middlewares: Function[];
  private _server: Server;

  constructor() {
    this._middlewares = [];
    // 内置一个服务对象
    this._server = new Server();
  }

  /**
   * 注册使用中间件
   * @param fn {Function}
   */
  public use(fn: Function): void {
    this._middlewares.push(fn);
  }

  /**
   * 开始监听服务
   * @param opts {Deno.ListenOptions} 监听地址和端口 
   * @param fn {Function} 监听执行后的回调
   */
  public async listen(opts: Deno.ListenOptions, fn: Function) {
    const that = this;
    const server = this._server;
    // 启动HTTP服务
    server.createServer(async function(ctx: Context) {
      const middlewares = that._middlewares;
      
      try {
        // 将HTTP服务生成的 HTTP上下文封装成一个安全操作的上下文
        // 安全HTTP上下文限制了所有中间件能操作的范围
        const sctx = new SafeContext(ctx);
        
        // 等待执行所有中间件
        await compose(middlewares)(sctx);
        const body = sctx.res.getBody();

        // 如果最后响应报文没有响应体,就默认设置404文案
        if (!(body && typeof body === "string" && body.length > 0)) {
          sctx.res.setBody("404 Not Found!");
        }
        // 最后写入响应数据
        await ctx.res.flush();
      } catch (err) {
        that._onError(err, ctx);
      }
    }); 
    server.listen(opts, fn);
  }

  /**
   * 统一错误处理
   * @param err {Error} 错误对象
   * @param ctx {SafeContext} 当前HTTP上下文
   */
  private async _onError(err: Error, ctx: Context) {
    console.log(err);
    if (ctx instanceof Context) {
      // 出现错误,把错误堆栈打印到页面上
      ctx.res.setBody(err.stack || 'Server Error');
      ctx.res.setStatus(500);
      await ctx.res.flush();
    } else {
      exit(1);
    }
  }
}

export { Application };

使用例子

./demo/web/example.ts

import { Application } from "./mod.ts";

const app = new Application();
const opts: Deno.ListenOptions = {
  hostname: "127.0.0.1",
  port: 3001
}

app.use(async function(ctx, next) {
  console.log('action 001');
  ctx.res.setBody("hello world! middleware-001");
  await next();
  console.log('action 006');
});

app.use(async function(ctx, next) {
  console.log('action 002');
  ctx.res.setBody("hello world! middleware-002");
  ctx.res.setStatus(200);
  // 提前设置结束
  // 页面将会渲染 "hello world! middleware-002"
  // 不会渲染 第三个中间件设置的响应体内容
  ctx.res.setFinish();
  await next();
  // throw new Error('hello this is testing error')
  console.log('action 005');
});

app.use(async function(ctx, next) {
  console.log('action 003');
  ctx.res.setBody("hello world! middleware-003");
  await next();
  console.log('action 004');
});

app.listen(opts, function() {
  console.log("the web is starting")
});

执行结果

deno run --allow-net example.ts

结果显示

  • 例子代码里,第二个中间件提前设置结束 ctx.res.setFinish()
  • 页面将会渲染 "hello world! middleware-002"
  • 不会渲染 第三个中间件设置的响应体内容

web_example_001

web_exmaple_002

使用例子异常情况

异常例子源码

import { Application } from "./mod.ts";

const app = new Application();
const opts: Deno.ListenOptions = {
  hostname: "127.0.0.1",
  port: 3001
}

app.use(async function(ctx, next) {
  console.log('action 001');
  ctx.res.setBody("hello world! middleware-001");
  await next();
  console.log('action 006');
});

app.use(async function(ctx, next) {
  console.log('action 002');
  ctx.res.setBody("hello world! middleware-002");
  await next();
  // 抛出异常
  throw new Error('hello this is testing error')
  console.log('action 005');
});

app.use(async function(ctx, next) {
  console.log('action 003');
  ctx.res.setBody("hello world! middleware-003");
  await next();
  console.log('action 004');
});

app.listen(opts, function() {
  console.log("the web is starting")
});

异常处理结果显示

web_example_003

web_example_004

测试

基准测试

  • 安装测试工具 npm i -g autocannon

发起100请求测试

  • autocannon http://127.0.0.1:3001/ -c 100
  • 就会出现以下结果 web_test_001

单元测试