我正在参与掘金创作者训练营第 4 期(带链接:juejin.cn/post/706419… ), 点击了解活动详情,一起学习吧!
一、前言
本篇文章可能需要你具备一些源码基础,在下面我会推荐一些资料。
推荐学习资料
标题 | 链接 |
---|---|
Koa 官网 | koajs.com/ |
Koa 源码 | github.com/koajs/koa |
Koa2 开发快速入门 | juejin.cn/post/704418… |
Koa2 原理解析 | juejin.cn/post/706907… |
Koa 是什么 ?有哪些部分组成 ?
Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。
当我们从github Koa 仓库克隆下 Koa 框架源码, 通过 lib 文件可以看到只有application.js
、context.js
、request.js
、response.js
四部分, Koa 正是由这四部分组成。
它们有什么联系 ?
- 通过 Koa 源码 的 package.json 文件,得到入口文件
lib/application.js
, 可知application.js
是 Koa 入口,也是应用库, 将 context、request、response 挂载到 Application 实例上 context.js
是上下文库request.js
是 request 库response.js
是 response 库
实现框架
当我们在读完源码后,就会很好的体会到框架编码风格,这时我们可能会想 Koa 框架精简,设计巧妙,能不能自己实现一个简易版本的 Koa,当然自己实现一个简易版本的 Koa,才能更好理解这门框架巧妙的设计
怎样去实现 Koa 呢,首先需要摸清作者的惯用手法,提纲挈领,找到入口, 绘制架构图。通过流程图或架构图的方式去理清整个框架脉络,再去一一实现功能。
当然也可以 “先临摹,再想着自创方式” 去实现。
你将学到哪些知识 ?
- 洋葱模型解析及简单实现
application
类核心实现context
类核心实现request
类核心实现response
类核心实现delegate
委托模式简单实现
二、Koa 最小系统
每一门语言开头总是从 Hello World
开始的, 像这样:
const Koa = require("koa");
const app = new Koa();
app.use((ctx, res) => {
res.end("Hello World");
});
app.listen(3000);
复制代码
通过这个简单示例, 我们试着去分析下:
- 凭借前端工程化的经验可以猜测得知 Koa 的入口文件应该在
package.json
的main
属性有标明; - Koa 实例是通过
new
关键字创建的, 也就是说在入口文件中, 默认导出的就是 Koa 应用类; - app 是 Koa 实例, 这个实例上挂载了
use
、listen
等方法。
那我们就简单实现一下, Koa
类, 具备 use
和 listen
方法;
class Koa {
constructor() {}
middleware = () => {};
listen(port, cb) {
const server = http.createServer((req, res) => {
this.middleware(req, res);
});
return server.listen(port, cb);
}
use(middlewareFn) {
this.middleware = middlewareFn;
return this;
}
}
const app = new Koa();
app.use((ctx, res) => {
res.end("Hello World");
});
app.listen(3000);
复制代码
简单解析
listen
实质就是对 http.createServer
的一层封装, 在创建成功后执行通过 use
方法挂载的函数middleware
。
这样我们就得到了 Koa 的最小系统, 后面我们的工作也是将这个思路一步步拆分。
想一想我们要实现的功能有哪些 ?
- Koa 中不止一个中间件, 而是会有很多中间件, 我们需要有一个中间件队列去存储中间件。
- 可以通过
use
方法, 将中间件全部保存下来, 方便后续操作。 - 在诸多中间件中, 每一个中间件都有自己的
ctx
。 - 原生 req 和 res 比较难用, 那就需要再处理下
request
请求和response
响应。 - 对于一些比较复杂的功能需要单独抽离, 如洋葱模型, Koa 源码中是直接引用的
koa-compose
插件。
对于一些插件, 如 koa-compose
、delegates
,我们会手动实现的。
需要把握的主线
我们本节会从三个主线去讲怎样实现一个 Koa 核心功能, 分别是
- 一条是沿
http.createServer
封装原生 Node 方向。 - 一条是沿构造
context
,request
,response
及错误处理 的方向。 - 最后一条是中间件机制解析与简单实现。
三、封装原生
这条主线是根据 http.createServer
封装原生 Node 来进行的, 这个主线我们在进行下分解:
- 构建 Application 实例, 也是 Koa 入口文件;
- 创建一个 http 服务, 成功时回调;
- 回调函数是通过
use
注册传入的函数, 可以通过listen
方法监听端口。
具体实现
// application.js
const http = require("http");
class Application extends Emitter {
constructor() {
super();
this.callbackFn;
}
listen(...args) {
let server = http.createServer(this.callback());
server.listen(...args);
}
// 收集中间件
use(fn) {
this.callbackFn = fn;
return this;
}
callback() {
return (req, res) => {
this.callbackFunc(req, res);
};
}
}
module.exports = Application;
复制代码
这实际上和我们前面实现的最小系统极为相似, 也比较简单。
四、构造对象及容错
在我们学习完主线一时,req 和 res 实际还是 Node 原生的 req 和 res,我们无法像 Koa 源码那样使用。这时构造context
、request
, response
对象的必要性就显现出来了。
这个主线我们在进行下分解:
- 使用 koa 时,会有
context
、request
,response
对象, 构造这些对象; - 通过源码分析,我们已知
context
对象主要功能是委托代理及框架层错误处理,request
对象是对 path、url 等属性的一些处理,response
对象是对 body、status 等属性的一些处理。 - 怎样把
context
、request
,response
串联起来的
构造 context
context
对象主要功能是对属性进行委托代理 —— 如ctx.response.body
代理后可以通过 ctx.body
拿到,以及一些中间层的错误捕获。
// context.js
let proto = {
onerror(err) {
console.log("框架层捕获函数");
this.app.emit("error", err);
// 中间层捕获异常后, 需要响应
this.status = 500;
let msg = "服务器内部错误";
this.res.setHeader("Content-Type", "text/plain; charset=utf-8");
this.res.setHeader("Content-Length", Buffer.byteLength(msg));
this.res.end(msg);
},
};
/**
* 用于获取对象上的属性
* @param {object} property
* @param {string} name
*/
function delegateGet(property, name) {
proto.__defineGetter__(name, function () {
return this[property][name];
});
}
/**
* 用于挂载对象上的属性和值
* @param {object} property
* @param {string} name
*/
function delegateSet(property, name) {
proto.__defineSetter__(name, function (val) {
this[property][name] = val;
});
}
let requestGet = ["query"];
let requestSet = [];
let responseGet = ["body", "status"];
let responseSet = responseGet;
requestGet.forEach((ele) => delegateGet("request", ele));
requestSet.forEach((ele) => delegateSet("request", ele));
responseGet.forEach((ele) => delegateGet("response", ele));
responseSet.forEach((ele) => delegateSet("response", ele));
module.exports = proto;
复制代码
此时 context
对象上已经有了 onerror
方法, 这个等会再聊, 接着往下看。
构造 request
request
对象是对 path、url 等属性的一些处理。
// request.js
const url = require("url");
module.exports = {
get query() {
// 导出了一个对象,其中包含了一个query的读取方法,通过url.parse方法解析url中的参数,并以对象的形式返回。
return url.parse(url.req.url).query;
},
};
复制代码
构造 response
response
对象是对 body、status 等属性的一些处理。
// 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("status code must be number type");
}
this.res.statusCode = statusCode;
},
};
复制代码
串联对象
- 将
context
、request
、response
挂载到 Application 实例上 - 使用 koa 时,会有 ctx 对象,构造中间层 ctx 对象
- 将 ctx 对象传递给回调函数
改造 application.js
const http = require("http");
const Emitter = require("events");
const context = require("./context");
const request = require("./request");
const response = require("./response");
class Application extends Emitter {
constructor() {
super();
this.callbackFunc;
// 每次都会构建新的context、request、response;
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
listen(...args) {
let server = http.createServer(this.callback());
server.listen(...args);
}
// 收集中间件
use(fn) {
this.callbackFn = fn;
}
callback() {
// 框架错误处理
if (!this.listenerCount("error")) this.on("error", this.onerror);
return (req, res) => {
let ctx = this.createContext(req, res);
this.callbackFn(ctx);
let respond = () => this.responseBody(ctx);
// 中间层处理错误
let onerror = (err) => ctx.onerror(err);
};
}
/**
* 用于构造 ctx 对象
* @param {Object} req Node原生的req实例
* @param {Object} res Node原生的res实例
*/
createContext(req, res) {
// 针对每一个请求
let ctx = Object.create(this.context);
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.app = ctx.request.app = ctx.response.app = this;
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
return ctx;
}
/**
* 处理响应请求
* @param {Object} ctx
*/
responseBody(ctx) {
let content = ctx.body;
if (typeof content === "string") {
ctx.res.end(content);
} else {
ctx.res.end(JSON.stringify(content));
}
}
onerror(err) {
let msg = err.stack || err.toString();
console.error(msg.replace(/^/g, " "));
}
}
module.exports = Application;
复制代码
- Emitter 是原生
events
的类, 原生事件驱动模块; - 在每一个 Koa 实例构造函数上构建新的
context
、request
、response
; createContext
方法是将context
、request
、response
三个对象都放到 ctx 对象上,
ctx.app
、ctx.request.app
、ctx.response.app
都可以拿this
对象,ctx.req
、ctx.request.req
可以拿到req
对象;ctx.res
、ctx.response.res
可以拿到res
对象;
responseBody
方法是对响应体的处理onerror
方法 是对错误信息的格式化
错误处理
实际上中间层在执行过程中出现错误, 捕捉到错误然后传递到框架进行处理, 目前虽然我们写了错误处理, 并未使用到, 我会在下面讲中间件机制时进行说明.
五、Koa-compose 解析及简单实现
相信这两幅图你也曾看到过, 这也是面试中常常探讨的中间件机制 (洋葱模型) ,也是面向切面编程 (AOP) 典型案例。
koa-compose 解析
function compose(middleware) {
if (!Array.isArray(middleware))
throw new TypeError("Middleware stack must be an array!");
for (const fn of middleware) {
if (typeof fn !== "function")
throw new TypeError("Middleware must be composed of functions!");
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
// 1. 返回出去的是一个匿名函数
// 2. 该函数的内部返回了一个函数
// Promise.resolve reject
return function (context, next) {
// 初始索引 -1
let index = -1;
return dispatch(0);
function dispatch(i) {
// 为了确保next只调用一次, 同一函数调用两次就会报错
if (i <= index)
return Promise.reject(new Error("next() called multiple times"));
index = i;
// 获取到中间件
let fn = middleware[i];
// 到了Promise的最后一个,让fn = 传递进来的 next, 退出条件
if (i === middleware.length) fn = next;
// next => undefined (koa调用时const fn = compose(this.middleware);)
if (!fn) return Promise.resolve();
// try catch 用于保证错误在Promise的情况能够正常捕获
try {
// 1. fn为一项中间件, dispatch.bind(null, i + 1))是 next 函数
// 2. 当中间件队列有两个中间件时,会先挂载第一个。
// 3. 第一个中间件使用时会调用next(); 如: app.use(async (ctx, next) => { await next();})
// 4. 此时next() 就是调用 dispatch.bind(null, i + 1), 以此来调用下一个中间件;
// 5. 当第二个中间件使用时调用next, 再触发dispatch函数, 此时 i 等于 middleware.length 就会退回
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}
复制代码
- 该函数返回一个函数, fn ==> fnMiddleware(ctx)
- middleware 须为数组队列, 且每一项都为 function
- 该函数 function (context, next);
compose 实现
// 简单实现洋葱模型
compose() {
return async (ctx) => {
let len = this.middlewares.length;
let next = async () => Promise.resolve();
for (let i = len - 1; i >= 0; i--) {
let curentMiddleware = this.middlewares[i];
next = createNext(curentMiddleware, next);
}
await next();
function createNext(middlewares, oldNext) {
return async () => await middlewares(ctx, oldNext);
}
};
}
复制代码
- 就是将中间件队列进行循环,取到每一项;
- createNext 实际就是将控制权传递给下一个中间件
六、完整的Application类
class Application extends Emitter {
constructor() {
super();
// this.callbackFunc;
this.middlewares = [];
// 每次都会构建新的context、request、response;
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
listen(...args) {
let server = http.createServer(this.callback());
server.listen(...args);
}
// 收集中间件
use(fn) {
// this.callbackFunc = fn;
this.middlewares.push(fn);
}
callback() {
// 框架错误处理
if (!this.listenerCount("error")) this.on("error", this.onerror);
return (req, res) => {
let ctx = this.createContext(req, res);
// this.callbackFunc(ctx);
let respond = () => this.responseBody(ctx);
// 中间层处理错误
let onerror = (err) => ctx.onerror(err);
// 先处理中间件,然后去响应
let fn = this.compose();
return fn(ctx).then(respond).catch(onerror);
};
}
/**
* 用于构造 ctx 对象
* @param {Object} req Node原生的req实例
* @param {Object} res Node原生的res实例
*/
createContext(req, res) {
// 针对每一个请求
let ctx = Object.create(this.context);
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.app = ctx.request.app = ctx.response.app = this;
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
return ctx;
}
// 简单实现洋葱模型
compose() {
return async (ctx) => {
let len = this.middlewares.length;
let next = async () => Promise.resolve();
for (let i = len - 1; i >= 0; i--) {
let curentMiddleware = this.middlewares[i];
next = createNext(curentMiddleware, next);
}
await next();
function createNext(middlewares, oldNext) {
return async () => await middlewares(ctx, oldNext);
}
};
}
/**
* 处理响应请求
* @param {Object} ctx
*/
responseBody(ctx) {
let content = ctx.body;
if (typeof content === "string") {
ctx.res.end(content);
} else {
ctx.res.end(JSON.stringify(content));
}
}
onerror(err) {
let msg = err.stack || err.toString();
console.error(msg.replace(/^/g, " "));
}
}
module.exports = Application;
复制代码
callback
方法中调用了createContext
方法拿到了完整的ctx 对象- 通过
responseBody
方法 对响应请求进行了处理. callback
方法中对compose
错误进行了捕获处理, 这个捕获会找到context
对象的onerror
方法, 进行冒泡。callback
方法中也会进行error
监听, 将错误信息格式化。
七、测试
const Koa = require("./lib/application");
const app = new Koa();
// app.use((req, res) => {
// res.writeHeader(200, { 'Content-Type': 'application/json'})
// res.end("hello world 水电费")
// })
app.use(async (ctx, next) => {
console.log('1');
await next();
console.log('6');
})
app.use(async (ctx, next) => {
console.log('2');
// 异常捕获错误
// throw new Error('New Error')
await next();
console.log('5');
})
app.use(async ctx => {
console.log('3');
ctx.res.setHeader('Content-type','text/html;charset=utf-8')
ctx.status = 404;
ctx.body = "服务异常!!! 请联系后端人员"
console.log('4');
})
app.listen(3001, () => {
console.log("hello Koa");
})
复制代码
输出
hello Koa
1
2
3
4
5
6
复制代码
八、总结
本文从Koa 是什么 ?有哪些部分组成 ?、怎样学源码及实现最小系统展开描述。
Koa 只是一个框架, 本文的学习方法可以适用在很多框架中, 当然你也有更好的学习方法, 也欢迎从评论区说出来, 一道成长。
同时,本篇文章是 Koa 系列第三篇,欢迎点赞、关注、支持一波。感谢!!!
我是前端小溪 欢迎感兴趣的同学关注下前端小溪公众号,也欢迎加我微信wxl-15153496335
。