Express and Koa are lightweight Web frameworks that are flexible and simple enough to start a server in a few lines of code, but as your business gets more complex, you’ll quickly find yourself manually configuring various middleware, and because such Web frameworks don’t constrain the directory structure of your project, As a result, the quality of projects produced by programmers of different skills varies greatly. Various Express and Koa based upper web frameworks, such as egg.js and Nest.js, have emerged in the community to address these issues

The company I work for now has also implemented an MVC development framework based on Koa and combined with its own business requirements. Our Node is mainly used to assume the BFF layer and does not involve real business logic, so the framework is a relatively simple encapsulation of Koa, built-in some common business components (such as authentication, proxy forwarding), through the agreed directory structure, automatic injection routes and some global methods

Recently, I simply read the source code of the framework, and the harvest is still very large, so I decided to implement a toy version of the MVC framework

Source code address

Framework using

Reference code – Step1

│ ├─ Controllers │ Home. Js │ ├─ Middlewares │ Index.js │ ├─ My-Node-MVC# Framework we will implement later| | ├ ─ services │ home. Js │ └ ─ views home. HTMLCopy the code

My-node-mvc is the MVC framework we will implement later. First, let’s see the final use effect

routes.js

const routes = [
  {
    match: '/'.controller: 'home.index'
  },
  {
    match: '/list'.controller: 'home.fetchList'.method: 'post'}];module.exports = routes;
Copy the code

middlewares/index.js

const middleware = () = > {
  return async (context, next) => {
    console.log('Custom Middleware');
    await next()
  }
}
module.exports = [middleware()];
Copy the code

app.js

const { App } = require('./my-node-mvc');
const routes = require('./routes');
const middlewares = require('./middlewares');

const app = new App({
  routes,
  middlewares,
});

app.listen(4445.() = > {
  console.log('app start at: http://localhost:4445');
})
Copy the code

My-node-mvc exposes an App class. We pass routes and middlewares to tell the framework how to render the route and start the middleware

When we visit http://localhost:4445, we first go through our custom middleware

async (context, next) => {
  console.log('Custom Middleware');
  await next()
}
Copy the code

It then matches the path in routes.js

{
  match: '/'.controller: 'home.index'
}
Copy the code

The framework then goes back to the Controllers folder and creates a new home object and calls its index method. The page renders the home.html folder in the Views folder

controllers/home.js

const { Controller } = require('.. /my-node-mvc');

// Exposes a Controller parent that all controllers inherit in order to inject this. CTX

// This. CTX has koA built-in methods and attributes, as well as custom methods and attributes extended by my-Node-MVC framework
class Home extends Controller {
  async index() {
    await this.ctx.render('home');
  }

  async fetchList() {
    const data = await this.ctx.services.home.getList(); ctx.body = data; }}module.exports = Home;
Copy the code

The same matching to visit http://localhost:4445/list

{
  match: '/list'.controller: 'home.fetchList'
}
Copy the code

The fetchList method of the Home object is called, which in turn calls the getList method of the Home object in the Services directory and returns JSON data

services/home.js

const { Service } = require('.. /my-node-mvc')

const posts = [{
  id: 1.title: 'Fate/Grand Order'}, {id: 2.title: 'Azur Lane',}];// Exposes a Service parent that all services inherit from in order to inject this. CTX objects
class Home extends Service {
  async getList() {
    return posts
  }
}

module.exports = Home
Copy the code

At this point, one of the simplest MVC Web flows has run

Before starting this tutorial, you’d better have some experience reading Koa source code, as in my previous article: Koa Source Code Analysis

Next, we will implement the my-Node-MVC framework step by step

The basic framework

Reference code – Step2

My-node-mvc is koA-based, so we need to install Koa first

npm i koa
Copy the code

my-node-mvc/app.js

const Koa = require('koa');

class App extends Koa {
  constructor(options={}) {
    super();
  }
}

module.exports = App;
Copy the code

We simply extend from the parent Koa class

my-node-mvc/index.js

// Export App
const App = require('./app');

module.exports = {
  App,
}
Copy the code

So let’s test that out

Go to step2
cd step2
node app.js
Copy the code

Visit http://localhost:4445/ and discover that the server is successfully started

Thus, one of the simplest packages has been completed

Built-in middleware

Our my-Node-MVC framework needs some basic middleware built in, such as KOA-BodyParser, KOA-Router, KoA-Views, etc. Only in this way can we avoid the trouble of repeatedly installing middleware every time we build a new project

Built-in middleware generally falls into two categories:

  • Built-in base middleware: for examplekoa-bodyparser.koa-router.metricsPerformance monitoring, health check
  • Built-in business middleware: The framework integrates functions common to all departments into business middleware based on business requirements, such as single sign-on (SSO) and file upload
npm i uuid koa-bodyparser ejs koa-views
Copy the code

Let’s try to create a new business middleware

my-node-mvc/middlewares/init.js

const uuid = require('uuid');

module.exports = () = > {
  // One requestId is generated per request
  return async (context, next) => {
    const id = uuid.v4().replace(/-/g.' ')
    context.state.global = {
      requestId: id
    }
    await next()
  }
}
Copy the code

my-node-mvc/middlewares/index.js

const init = require('./init');
const views = require('koa-views');
const bodyParser = require('koa-bodyparser');

// Export business middleware init and base middleware koa-bodyParser koa-views
module.exports = {
  init,
  bodyParser,
  views,
}
Copy the code

Now we need to call these middleware during App initialization

my-node-mvc/index.js

const Koa = require('koa');
const middlewares = require('./middlewares');

class App extends Koa {
  constructor(options={}) {
    super(a);const { projectRoot = process.cwd(), rootControllerPath, rootServicePath, rootViewPath } = options;
    this.rootControllerPath = rootControllerPath || path.join(projectRoot, 'controllers');
    this.rootServicePath = rootServicePath || path.join(projectRoot, 'services');
    this.rootViewPath = rootViewPath || path.join(projectRoot, 'views');

    this.initMiddlewares();
  }

  initMiddlewares() {
    // Use this. Use to register middleware
    this.use(middlewares.init());
    this.use(middlewares.views(this.rootViewPath, { map: { html: 'ejs'}}))this.use(middlewares.bodyParser()); }}module.exports = App;
Copy the code

Modify the startup step2/app.js

app.use((ctx) = > {
  ctx.body = ctx.state.global.requestId
})

app.listen(4445.() = > {
  console.log('app start at: http://localhost:4445');
})
Copy the code

Each visit to http://localhost:4445 returns a different requestId

Business middleware

In addition to the my-Node-MVC built-in middleware, we can also pass in our own written middleware and have my-Node-MVC boot up for us

step2/app.js

const { App } = require('./my-node-mvc');
const routes = require('./routes');
const middlewares = require('./middlewares');

Middlewares, our business middleware, is passed in as an array
const app = new App({
  routes,
  middlewares,
});

app.use((ctx, next) = > {
  ctx.body = ctx.state.global.requestId
})

app.listen(4445.() = > {
  console.log('app start at: http://localhost:4445');
})
Copy the code

my-node-mvc/index.js

const Koa = require('koa');
const middlewares = require('./middlewares');

class App extends Koa {
  constructor(options={}) {
    super(a);this.options = options;

    this.initMiddlewares();
  }

  initMiddlewares() {
    // Receive incoming business middleware
    const { middlewares: businessMiddlewares } = this.options;
    // Use this. Use to register middleware
    this.use(middlewares.init())
    this.use(middlewares.bodyParser());

    // Initialize the business middleware
    businessMiddlewares.forEach(m= > {
      if (typeof m === 'function') {
        this.use(m);
      } else {
        throw new Error('Middleware must be functions'); }}); }}module.exports = App;
Copy the code

So our business middleware can start up successfully

step2/middlewares/index.js

const middleware = () = > {
  return async (context, next) => {
    console.log('Custom Middleware');
    await next()
  }
}

module.exports = [middleware()];
Copy the code

The global method

We know that Koa’s built-in CTX object already has a lot of methods mounted on it, such as ctx.cookie.get () ctx.remove() and so on, but in our my-Node-MVC framework we can actually add some global methods as well

How do I continue to add methods on CTX? The general idea is to write middleware that mounts methods on CTX:

const utils = () = > {
  return async (context, next) => {
    context.sayHello = () = > {
      console.log('hello');
    }
    await next()
  }
}

// Use middleware
app.use(utils());

// All subsequent middleware can use this method
app.use((ctx, next) = > {
  ctx.sayHello();
})
Copy the code

However, this requires us to place the utils middleware at the top level so that subsequent middleware can continue to use this method

Another way to think about it is that each time a client sends an HTTP request, Koa calls the createContext method, which returns a new CTX, which is then passed to the middleware

The key is createContext. We can override the createContext method to inject our global method before passing CTX to the middleware

my-node-mvc/index.js

const Koa = require('koa');

class App extends Koa {
  
  createContext(req, res) {
    // Call the parent method
    const context = super.createContext(req, res);
    // Inject global methods
    this.injectUtil(context);

    / / return CTX
    return context
  }

  injectUtil(context) {
    context.sayHello = () = > {
      console.log('hello'); }}}module.exports = App;
Copy the code

Match the routing

Reference code – Step3

We specify the routing rules for the framework:

const routes = [
  {
    match: '/'.// Match the path
    controller: 'home.index'.// Match controller and method
    middlewares: [middleware1, middleware2], // The routing level middleware passes through the routing middleware and finally reaches some method of the controller
  },
  {
    match: '/list'.controller: 'home.fetchList'.method: 'post'.// Matches the HTTP request}];Copy the code

How to implement this configuration route via koa-router?

# https://github.com/ZijianHe/koa-router/issues/527#issuecomment-651736656
# koa-router 9.x has been upgraded to path-to-regexp
# the router. The get (' / * '(CTX) = > {CTX. Body =' ok '}) into this type of writing: the router, the get (" (. *) ", (CTX) = > {CTX. Body = 'ok'})
npm i koa-router
Copy the code

New built-in routing middleware my – node – the MVC/middlewares/router. Js

const Router = require('koa-router');
const koaCompose = require('koa-compose');

module.exports = (routerConfig) = > {
  const router = new Router();

  // Todo matches the routerConfig route configuration passed in

  return koaCompose([router.routes(), router.allowedMethods()])
}
Copy the code

Note that I ended up using koaCompose to combine the two methods into one, because the original koa-Router method required two calls of use to register the middleware

const router = new Router();

router.get('/'.(ctx, next) = > {
  // ctx.router available
});

app
  .use(router.routes())
  .use(router.allowedMethods());
Copy the code

After using KoaCompose, we only need to call use once when we register

class App extends Koa {
  initMiddlewares() {
    const { routes } = this.options;
    
    // Register the route
    this.use(middlewares.route(routes)); }}Copy the code

Now let’s implement the specific routing matching logic:

module.exports = (routerConfig) = > {
  const router = new Router();

  if (routerConfig && routerConfig.length) {
    routerConfig.forEach((routerInfo) = > {
      let { match, method = 'get', controller, middlewares } = routerInfo;
      let args = [match];

      if (method === The '*') {
        method = 'all'
      }

      if ((middlewares && middlewares.length)) {
        args = args.concat(middlewares)
      };

      controller && args.push(async (context, next) => {
        // Todo finds controller
        console.log('233333');
        await next();
      });


      if (router[method] && router[method].apply) {
        // apply
        // router.get('/demo', fn1, fn2, fn3);
        router[method].apply(router, args)
      }
    })
  }

  return koaCompose([router.routes(), router.allowedMethods()])
}
Copy the code

A neat trick of this code is to use an array of ARGs to collect routing information

{
  match: '/neko'.controller: 'home.index'.middlewares: [middleware1, middleware2],
  method: 'get'
}
Copy the code

The routing information, if matched with the KOA-Router, would look like this:

Middleware1 and middleware2 are the routing level middleware we pass in
// Finally the request is passed to the home.index method
router.get('/neko', middleware1, middleware2, home.index);
Copy the code

Since the matching rules are generated dynamically, we can’t write them dead like above, so we have this trick:

const method = 'get';

// Collect dynamic rules through arrays
const args = ['/neko', middleware1, middleware2, async (context, next) => {
  // Call the controller method
  await home.index(context, next);
}];

// Finally use apply
router[method].apply(router, args)

Copy the code

Into the Controller

In the previous routing middleware, we were missing the most critical step: finding the corresponding Controller object

controller && args.push(async (context, next) => {
  // Todo finds controller
  await next();
});
Copy the code

We’ve already agreed that the controllers folder of the project will hold Controller objects by default, so just walk through that folder, find a file named home.js, and call the Controller method

npm i glob
Copy the code

New my – node – the MVC/loader/controller. Js

const glob = require('glob');
const path = require('path');

const controllerMap = new Map(a);// Cache file name and corresponding path
const controllerClass = new Map(a);// Cache the file name and the corresponding require object

class ControllerLoader {
  constructor(controllerPath) {
    this.loadFiles(controllerPath).forEach(filepath= > {
      const basename = path.basename(filepath);
      const extname = path.extname(filepath);
      const fileName = basename.substring(0, basename.indexOf(extname));

      if (controllerMap.get(fileName)) {
        throw new Error('Controller folder${fileName}File with the same name! `)}else{ controllerMap.set(fileName, filepath); }})}loadFiles(target) {
    const files = glob.sync(`${target}/**/*.js`)
    return files
  }

  getClass(name) {
    if (controllerMap.get(name)) {
      if(! controllerClass.get(name)) {const c = require(controllerMap.get(name));
        // Require this file only if it is used for a controller
        controllerClass.set(name, c);
      }
      return controllerClass.get(name);
    } else {
      throw new Error('Controller folder is not there${name}File `)}}}module.exports = ControllerLoader
Copy the code

Since there may be a lot of files in the Controllers folder, we don’t need to require all of them when the project starts. When a request calls home Controller, we dynamically load require(‘/my-app/controllers/home’). The same module id is cached by Node when it is loaded for the first time and retrieved from the cache when it is loaded again

Modify my – node – the MVC/app. Js

const ControllerLoader = require('./loader/controller');
const path = require('path');

class App extends Koa {
  constructor(options = {}) {
    super(a);this.options = options;

    const { projectRoot = process.cwd(), rootControllerPath } = options;
    // The default controllers directory. You can specify other paths by setting rootControllerPath
    this.rootControllerPath = rootControllerPath || path.join(projectRoot, 'controllers'); 
    this.initController();
    this.initMiddlewares();
  }

  initController() {
    this.controllerLoader = new ControllerLoader(this.rootControllerPath);
  }

  initMiddlewares() {
    // Pass controllerLoader to the routing middleware
    this.use(middlewares.route(routes, this.controllerLoader))
  }
}

module.exports = App;
Copy the code

my-node-mvc/middlewares/router.js

// omit other code

controller && args.push(async (context, next) => {
  // Find controller home.index
  const arr = controller.split('. ');
  if (arr && arr.length) {
    const controllerName = arr[0]; // home
    const controllerMethod = arr[1]; // index
    const controllerClass = loader.getClass(controllerName); // Obtain the class from loader

    // The controller needs a new context every time it is requested
    // Pass context and next
    const controller = new controllerClass(context, next);
    if (controller && controller[controllerMethod]) {
      awaitcontroller[controllerMethod](context, next); }}else {
    awaitnext(); }});Copy the code

My new – node – the MVC/controller. Js

class Controller {
  constructor(ctx, next) {
    this.ctx = ctx;
    this.next = next; }}module.exports = Controller;
Copy the code

Our my-Node-MVC will provide a Controller base class from which all business Controllers will inherit, so the method will fetch this.ctx

my-node-mvc/index.js

const App = require('./app');
const Controller = require('./controller');

module.exports = {
  App,
  Controller, / / Controller
}
Copy the code
const { Controller } = require('my-node-mvc');

class Home extends Controller {
  async index() {
    await this.ctx.render('home'); }}module.exports = Home;
Copy the code

Inject Services

const { Controller } = require('my-node-mvc');

class Home extends Controller {
  async fetchList() {
    const data = await this.ctx.services.home.getList(); ctx.body = data; }}module.exports = Home;
Copy the code

The this.ctx object will mount a Services object that contains all the service objects in the services folder of the project root directory

My new – node – the MVC/loader/service. The js

const path = require('path');
const glob = require('glob');

const serviceMap = new Map(a);const serviceClass = new Map(a);const services = {};

class ServiceLoader {
  constructor(servicePath) {
    this.loadFiles(servicePath).forEach(filepath= > {
      const basename = path.basename(filepath);
      const extname = path.extname(filepath);
      const fileName = basename.substring(0, basename.indexOf(extname));

      if (serviceMap.get(fileName)) {
        throw new Error('Servies' folder${fileName}File with the same name! `)}else {
        serviceMap.set(fileName, filepath);
      }

      const _this = this;

      Object.defineProperty(services, fileName, {
        get() {
          if (serviceMap.get(fileName)) {
            if(! serviceClass.get(fileName)) {// Require this file only when a service is used
              const S = require(serviceMap.get(fileName));
              serviceClass.set(fileName, S);
            }
            const S = serviceClass.get(fileName);
            // A new Service instance is created each time
            / / in the context
            return newS(_this.context); }}})}); }loadFiles(target) {
    const files = glob.sync(`${target}/**/*.js`)
    return files
  }

  getServices(context) {
    / / update the context
    this.context = context;
    returnservices; }}module.exports = ServiceLoader
Copy the code

This code is basically the same as my-node-mvc/loader/controller.js, except that object.defineProperty defines the get method of the “Services” Object. (‘/my-app/services/home’)

Then we also need to mount the Services object onto the CTX object. Remember how we defined global methods earlier? It’s still the same routine (packaged thousand layer routine)

class App extends Koa {

  constructor() {
    this.rootViewPath = rootViewPath || path.join(projectRoot, 'views');
    this.initService();
  }

  initService() {
    this.serviceLoader = new ServiceLoader(this.rootServicePath);
  }

  createContext(req, res) {
    const context = super.createContext(req, res);
    // Inject global methods
    this.injectUtil(context);

    / / into Services
    this.injectService(context);

    return context
  }

  injectService(context) {
    const serviceLoader = this.serviceLoader;

    // Add a services object to the context
    Object.defineProperty(context, 'services', {
      get() {
        return serviceLoader.getServices(context)
      }
    })
  }
}
Copy the code

Similarly, we need to provide a Service base class from which all business services will inherit

New my – node – the MVC/service. Js

class Service {
  constructor(ctx) {
    this.ctx = ctx; }}module.exports = Service;
Copy the code

my-node-mvc/index.js

const App = require('./app');
const Controller = require('./controller');
const Service = require('./service');

module.exports = {
  App,
  Controller,
  Service, / / Service
}
Copy the code
const { Service } = require('my-node-mvc');

const posts = [{
  id: 1.title: 'this is test1'}, {id: 2.title: 'this is test2',}];class Home extends Service {
  async getList() {
    returnposts; }}module.exports = Home;
Copy the code

conclusion

This article encapsulates a very basic MVC framework based on Koa2 from scratch, hoping to provide readers with some ideas and inspiration for framework encapsulation. For more details on the framework, you can see my little-Node-MVC

Of course, the package in this article is very rudimentary, but you can go on to add more functionality based on your company’s situation: for example, provide a my-Node-MVC-template project template, and develop a command-line tool my-Node-MVC-cli to pull and create templates

Among them, the combination of built-in middleware and framework can be seen as injecting the real soul into the package. Our company has encapsulated many common business middleware internally: Authentication, log, performance monitoring, full link tracking, configuration center and other private NPM packages, through the development of the Node framework can be very convenient integration, while using scaffolding tools, provide out-of-the-box project templates, for business to reduce a lot of unnecessary development and operation and maintenance costs