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 example
koa-bodyparser
.koa-router
.metrics
Performance 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