从零实现一个NodeJS MVC框架

154 阅读5分钟

本文的目标是从NodeJs的基础模块出发,去构建一个基础的web mvc框架。

从零实现一个简易版koa

在开始实现一个简易版koa之前,我们先看下koa的使用方式。

const Koa = require('koa');
const app = new Koa();

// logger

app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});


// response
app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

从以上的使用方式中,我们可以看到有几个重要元素,然后我们的任务就是一一实现这些元素

application

先大概定义下 application 这个类,然后描述下接口

const http = require("http");
const EventEmitter = require("events");
const compose = require("./compose");

class Application extends EventEmitter {
	constructor() {
    super();
    this.middlewares = [];
  }

  // 创建一个http服务器
  listen(...args) {
    const server = http.createServer(this.callback());
    server.listen(...args);
  }

  // 中间件使用
  use(fn) {
    this.middlewares.push(fn);
  }
  
  /**
   * 1. http server 创建之后需要对原生 req, res 进行封装
   * 2. 需要将加载了的中间件串行执行
   * 3. 中间件执行完成后需要将消息发送给客户端
   * 4. 错误处理
   */
  callback() {
    return (req, res) => {
      const ctx = this.createContext(req, res);
      const fn = compose(this.middlewares);
      fn(ctx)
        .then(() => this.respond(ctx))
        .catch((e) => this.onError(e, ctx));
    };
  }
  
  /**
   * 包装原生req, res构造ctx
   * @param {*} req
   * @param {*} res
   */
  createContext(req, res) {}
 
  /**
   * 回复客户端消息
   * @param {*} ctx
   */
  respond(ctx) {}

  /**
   * 错误处理
   * @param {Object} e
   * @param {Object} ctx
   */
  onError(e, ctx) {}
}

module.exports = Application;

然后基于原生req, res进行封装,创建一个上下文环境

  createContext(req, res) {
    const ctx = {};
    
    ctx.request = {};
    ctx.response = {};
    
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    return ctx;
  }

组合中间件,顺序执行

koa-compose 的实现是递归执行,这里将尾递归可稍稍优化一下

module.exports = (middlewares) => {
  const len = middlewares.length;
  let next = async function () {
    return Promise.resolve({});
  };

  /**
   * 创建一个next函数
   * 该函数被调用时会执行当前中间件的下一个中间件
   */
  function createNext(ctx, middleware, oldNext) {
    return async () => {
      await middleware(ctx, oldNext);
    };
  }

  return async (ctx) => {
    for (let i = len - 1; i >= 0; i--) {
      next = createNext(ctx, middlewares[i], next);
    }
    await next();
  };
};

统一回复客户端消息

/**
   * 回复客户端消息
   * @param {*} ctx
   */
respond(ctx) {
  const content = ctx.body;
  if (typeof content === "string") {
    ctx.res.end(content);
  } else if (typeof content === "object") {
    ctx.res.end(JSON.stringify(content));
  }
}

错误处理

/**
   * 错误处理
   * @param {Object} e
   * @param {Object} ctx
   */
onError(e, ctx) {
  if (e.code === "ENOENT") {
    ctx.status = 404;
  } else {
    ctx.status = 500;
  }
  const msg = e.message || "Internal error";
  ctx.res.end(msg);
  // 触发 error 事件
  this.emit("error", e);
}

到这里我们已经基本完成了一个简易koa的基本功能,但是细心的大家肯定发现了ctx.body, ctx.status类似的调用我们还没有定义,所以我们现在需要对context, request, response 这三个对象进行扩展

// application.js
class Application extends EventEmitter {
  constructor() {
    super();
    this.middlewares = [];
    this.context = context;
    this.request = request;
    this.response = response;
  }
 
  /**
     * 构造ctx
     * @param {*} req
     * @param {*} res
     */
  createContext(req, res) {
    // 分别对context, request, response 进行扩展
    const ctx = Object.create(this.context);

    ctx.request = Object.create(this.request);
    ctx.response = Object.create(this.response);

    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    return ctx;
  }
  ...
}

// response.js
  module.exports = {
    get body() {
      return this._body;
    },
    set body(data) {
      this._body = data;
    },
    get status() {
      return this.res.statusCode;
    },
    set status(statusCode) {
      if (typeof statusCode !== "number") {
        throw new Error("statusCode must be a number");
      }
      this.res.statusCode = statusCode;
    },
  };

// request.js

const url = require("url");

module.exports = {
  // 代理到原生 req 上
  get query() {
    return url.parse(this.req.url, true).query;
  },
};

// context.js
const { delegateGet, delegateSet } = require("../utils");
const proto = {};

// 定义request中要代理的setter和 getter
const requestSet = [];
const requestGet = ["query"];

// 定义response中要代理的setter和 getter
const responseSet = ["body", "status"];
const responseGet = responseSet;

// 将属性分别代理到 request 和 response 上

requestSet.forEach((ele) => {
  delegateSet(proto, "request", ele);
});

requestGet.forEach((ele) => {
  delegateGet(proto, "request", ele);
});

responseSet.forEach((ele) => {
  delegateSet(proto, "response", ele);
});

responseGet.forEach((ele) => {
  delegateGet(proto, "response", ele);
});

module.exports = proto;
// 代理方法
function delegateGet(target, prop, ele) {
  const descriptor = Object.getOwnPropertyDescriptor(target, ele);
  Object.defineProperty(target, ele, {
    ...descriptor,
    configurable: true,
    enumerable: true,
    get() {
      return this[prop][ele];
    },
  });
}

function delegateSet(target, prop, ele) {
  const descriptor = Object.getOwnPropertyDescriptor(target, ele);
  Object.defineProperty(target, ele, {
    ...descriptor,
    configurable: true,
    enumerable: true,
    set(data) {
      this[prop][ele] = data;
    },
  });
}

基于koa实现一个简易版MVC框架

mvc框架的重点在于通过业务逻辑,数据、界面显示分离的方式去组织代码,一个基本的mvc组织形式,目录结构如下

.
├── app.js
├── controllers // 和用户交互,处理输入,向模型发送数据,处理输出
├── middlewares
├── services // 复杂业务逻辑封装
├── static // 静态文件目录
└── views // 视图文件,html

Controller

首先我们需要去定义一下controller文件的格式

const homeController = async (ctx, next) => {
  ctx.render("index.html", { title: "welcome" }); // render方法用来返回一个独立的html文件
};

// 路由处理逻辑
const signController = async (ctx, next) => {
  const email = ctx.request.body.email || "";
  const password = ctx.request.body.password || "";
  console.log(`signin with email: ${email}, password: ${password}`);
  if (email === "koa@ytest.com" && password === "12345") {
    ctx.render("sign-ok.html", {
      title: "sign in ok",
      name: "xiong",
    });
  } else {
    ctx.render("sign-fail.html", {
      title: "fail with error",
    });
  }
};

module.exports = {
  "GET /": homeController, // 路由信息
  "POST /signin": signController,
};

然后需要写一个koa中间件去自动扫描controllers目录下的controller文件,完成路由的注册

const fs = require("fs");
const path = require("path");
const router = require("koa-router")();

// 注册路由处理函数
function registerUrl(router, urlMap) {
  for (const [key, value] of Object.entries(urlMap)) {
    if (key.startsWith("GET")) {
      const path = key.substring(4);
      router.get(path, urlMap[key]);
      console.log(`register URL mapping: GET ${path}`);
    } else if (key.startsWith("POST")) {
      const path = key.substring(5);
      router.post(path, urlMap[key]);
    } else {
      console.log(`invalid URL: ${key}`);
    }
  }
}

function readControllerFile(router, dir) {
  const files = fs.readdirSync(dir);
  const jsFiles = files.filter((item) => item.endsWith(".js"));
  for (let file of jsFiles) {
    console.log(`开始处理controller:${file}...`);
    const mappping = require(path.join(dir, file));
    registerUrl(router, mappping);
  }
}

module.exports = (dir) => {
  const real_dir = dir || path.resolve(__dirname, "../controllers");
  readControllerFile(router, real_dir);
  return router.routes();
};

templating

从上面的controller中看到,调用了ctx.render方法,这个方法其实做的事情就是读取views目录下相关的模板文件(通常是html)返回给客户端,在以下的实现中使用了nunjucks作为模板引擎。

const nunjucks = require("nunjucks");

function createEnv(path, opts) {
  const autoescape = opts.autoescape === undefined ? true : opts.autoescape;
  const noCache = opts.noCache || false;
  const watch = opts.watch || false;
  const throwOnUndefined = opts.throwOnUndefined || false;
  // 从 path 中搜索 template 文件
  const env = new nunjucks.Environment(
    new nunjucks.FileSystemLoader(path, {
      noCache,
      watch,
    }),
    {
      autoescape,
      throwOnUndefined,
    }
  );
  if (opts.filters) {
    for (const [key, value] of Object.entries(opts.filters)) {
      env.addFilter(key, value);
    }
  }
  return env;
}

function templating(path, opts) {
  const env = createEnv(path, opts);
  return async (ctx, next) => {
    ctx.render = function (view, model) {
      ctx.response.body = env.render(
        view,
        Object.assign({}, ctx.state || {}, model || {})
      );
      ctx.response.type = "text/html";
    };
    await next();
  };
}
module.exports = templating;

处理静态文件

在某些时候,需要返回存储在服务器端一些前端资源文件例如css文件、图片文件,所以需要一个中间件去处理静态资源

const path = require("path");
const mime = require("mime");
const fs = require("mz/fs");

function staticFiles(url, dir) {
  return async function (ctx, next) {
    const rpath = ctx.request.path;
    if (rpath.startsWith(url)) {
      const fp = path.join(dir, rpath.substring(url.length));
      const isExist = await fs.exists(fp);
      if (isExist) {
        ctx.response.type = mime.getType(rpath);
        ctx.response.body = await fs.readFile(fp);
      } else {
        ctx.response.status = 404;
      }
    } else {
      // 不是指定前缀的 url, 继续处理下一个middleware
      await next();
    }
  };
}
module.exports = staticFiles;

到此,我们就完成了一个最基本的mvc框架。其实所有的扩展能力都是基于koa的中间件来实现的,如果需要更多高级的能力,可以自行基于中间件逻辑去封装。

参考文档