本文的目标是从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的中间件来实现的,如果需要更多高级的能力,可以自行基于中间件逻辑去封装。
参考文档
- koa文档:koajs.com/
- koa-compose: github.com/koajs/compo…
- eggjs: github.com/eggjs/egg
- nunjucks: nunjucks.bootcss.com/
- koa-router: www.npmjs.com/package/koa…