The goal of this article is to build a basic Web MVC framework from the base modules of NodeJs.

Implement a simplified version of KOA from scratch

Before we start implementing a simple version of KOA, let’s take a look at how KOA is used.

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);
Copy the code

From the above usage, we can see that there are several important elements, and then our task is to implement them

application

Define the Application class briefly, and then describe the interface

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

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

  // Create an HTTP server
  listen(. args) {
    const server = http.createServer(this.callback()); server.listen(... args); }// Middleware usage
  use(fn) {
    this.middlewares.push(fn);
  }
  
  /** * 1. HTTP server needs to encapsulate native REq, RES * 2. The loaded middleware needs to be executed in serial * 3. The message needs to be sent to the client after the completion of the middleware execution * 4. Error handling */
  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));
    };
  }
  
  /** * packing native REq, res construct CTX *@param {*} req
   * @param {*} res* /
  createContext(req, res) {}
 
  /** * Reply to client message *@param {*} ctx* /
  respond(ctx) {}

  /** * error handling *@param {Object} e
   * @param {Object} ctx* /
  onError(e, ctx){}}module.exports = Application;
Copy the code

Then encapsulate it based on native REQ and RES to create a context

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

Combined middleware, sequential execution

Koa-compose is implemented recursively, and the tail recursion can be slightly optimized here

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

  /** * Creates a next function * which is called to execute the next middleware of the current middleware */
  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();
  };
};

Copy the code

Reply to client messages in a unified manner

/** * Reply to client message *@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)); }}Copy the code

Error handling

/** * error handling *@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);
  // Raises an error event
  this.emit("error", e);
}
Copy the code

At this point we have basically completed the basic functionality of a simple KOA, but you will notice that the ctx.body and ctx.status calls have not been defined yet, so we now need to extend context, Request, and Response

// application.js
class Application extends EventEmitter {
  constructor() {
    super(a);this.middlewares = [];
    this.context = context;
    this.request = request;
    this.response = response;
  }
 
  /** * construct CTX *@param {*} req
     * @param {*} res* /
  createContext(req, res) {
    // Extend context, request, and response respectively
    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;
    returnctx; }... }// 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 (typeofstatusCode ! = ="number") {
        throw new Error("statusCode must be a number");
      }
      this.res.statusCode = statusCode; }};// request.js

const url = require("url");

module.exports = {
  // Proxy to native REq
  get query() {
    return url.parse(this.req.url, true).query; }};// context.js
const { delegateGet, delegateSet } = require(".. /utils");
const proto = {};

// Define the setters and getters to proxy in the request
const requestSet = [];
const requestGet = ["query"];

// Define the setter and getter to proxy in response
const responseSet = ["body"."status"];
const responseGet = responseSet;

// Delegate attributes to request and response, respectively

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;

Copy the code
// Proxy method
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; }}); }Copy the code

Implement a simple MVC framework based on KOA

MVC framework focuses on organizing code by separating business logic, data and interface display. A basic MVC organization form is as follows: directory structure

.Flag ─ app.js Flag ─ Controllers// Interact with the user, process input, send data to the model, process output├ ─ ─ middlewares ├ ─ ─ services// Complex business logic encapsulation├ ─ ─static // Static file directory└ ─ ─ views// View file, HTML
Copy the code

Controller

First we need to define the format of the Controller file

const homeController = async (ctx, next) => {
  ctx.render("index.html", { title: "welcome" }); The render method is used to return a separate HTML file
};

// Route processing logic
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 === "[email protected]" && 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, // Routing information
  "POST /signin": signController,
};

Copy the code

A KOA middleware is then written to automatically scan the Controllers directory to register the route

const fs = require("fs");
const path = require("path");
const router = require("koa-router") ();// Register the route handler
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('start handling 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();
};

Copy the code

templating

As you can see from the controller above, the ctx.render method is called. All this does is read the relevant template file (usually HTML) in the views directory and return it to the client. In the following implementation, Nunjucks is used as the template engine.

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;
  // Search the template file from path
  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;

Copy the code

Working with static files

At some point, you need to return some front-end resource files such as CSS files and image files stored on the server side, so you need a middleware to handle static resources

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 that is not the specified prefix, proceed to the next middleware
      awaitnext(); }}; }module.exports = staticFiles;

Copy the code

At this point, we have completed a basic MVC framework. In fact, all the expansion capabilities are based on koA middleware to achieve, if you need more advanced capabilities, can be packaged based on middleware logic.

Reference documentation