koajs — одна из самых популярных серверных сред nodejs.Многие веб-сайты используют koa для разработки.В то же время в сообществе появилось большое количество фреймворков корпоративного уровня, основанных на упаковке koa. Однако за этими ослепительными достижениями стоит сама кодовая база koa, являющаяся основным движком, очень оптимизированная и не может не удивлять своим гениальным дизайном.
В обычной работе автор активно использует коа, поэтому меня, естественно, очень интересуют принципы, лежащие в его основе, поэтому я исследовал его в свободное время. Тем не менее, эта статья не является анализом исходного кода, а с противоположной точки зрения, чтобы показать вам, как разработать и внедрить инфраструктуру koa с нуля, В этом процессе будут показаны наиболее важные концепции и принципы koa. Я верю, что после прочтения этой статьи у вас будет более глубокое понимание коа, в то же время после прочтения этой статьи вы прочтете исходный код коа, и ваше мышление будет очень плавным.
Сначала выпустите адрес кодовой базы koa framework, реализованной автором:simpleKoa
Следует отметить, что koa, реализованный в этой статье, является версией koa 2, основанной на async/await, поэтому версия узла должна быть выше 7.6. Если версия узла читалки ниже, рекомендуется обновиться, либо установить babel-cli и использовать в нем babel-node для запуска примера.
четыре основные линии
Автор считает, что для понимания коа в основном необходимо понять четыре основные линии, которые на самом деле являются четырьмя шагами для реализации коа, а именно
- Инкапсулировать узел HTTP-сервер
- Создание запроса, ответа, объектов контекста
- промежуточный механизм
- обработка ошибок
Ниже мы проанализируем их один за другим.
Основная строка 1: инкапсулирующий узел http-сервер: начиная с hello world
Прежде всего, независимо от фреймворка, если вы используете собственный модуль http для реализации серверного приложения, которое возвращает hello world, код выглядит следующим образом:
let http = require('http');
let server = http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world');
});
server.listen(3000, () => {
console.log('listenning on 3000');
});
Первым шагом к реализации koa является инкапсуляция этого нативного процесса.Для этого мы сначала создадим application.js для реализации объекта Application:
// application.js
let http = require('http');
class Application {
/**
* 构造函数
*/
constructor() {
this.callbackFunc;
}
/**
* 开启http server并传入callback
*/
listen(...args) {
let server = http.createServer(this.callback());
server.listen(...args);
}
/**
* 挂载回调函数
* @param {Function} fn 回调处理函数
*/
use(fn) {
this.callbackFunc = fn;
}
/**
* 获取http server所需的callback函数
* @return {Function} fn
*/
callback() {
return (req, res) => {
this.callbackFunc(req, res);
};
}
}
module.exports = Application;
Затем создайте example.js:
let simpleKoa = require('./application');
let app = new simpleKoa();
app.use((req, res) => {
res.writeHead(200);
res.end('hello world');
});
app.listen(3000, () => {
console.log('listening on 3000');
});
Видно, что мы изначально завершили инкапсуляцию http-сервера, в основном для достиженияapp.use
зарегистрировать функцию обратного вызова,app.listen
Синтаксический сахар запускает сервер и передает функцию обратного вызова, типичный стиль коа.
Но ложка дегтя в том, что параметры функции обратного вызова, которые мы передаем, по-прежнему используютreq
иres
, то есть нативные объекты запроса и ответа узла.Методы, предоставляемые этими нативными объектами и API, недостаточно удобны и не соответствуют простоте использования, которую должен обеспечить фреймворк. Поэтому нам нужно войти во вторую основную линию.
Основная строка 2: Создание запроса, ответа, объектов контекста
Если вы прочтете документацию по коа, вы обнаружите, что у коа есть три важных объекта: запрос, ответ и контекст. где запрос является родным для узлаrequest
инкапсуляция, ответ является родным для узлаresponse
инкапсуляция объекта,context
Объект является объектом контекста функции обратного вызова, который монтирует объекты запроса и ответа koa. Давайте объясним их один за другим.
Первое, что нужно прояснить, это то, что для объектов запроса и ответа koa он обеспечивает только инкапсуляцию некоторых методов собственных объектов запроса и ответа узла. Это ясно. Наша идея состоит в том, чтобы использовать свойства getter и setter js, на основе узла Объект req/res объекта инкапсулирует объект запроса/ответа koa.
Спланируйте, какие простые в использовании методы мы хотим инкапсулировать. Здесь в статье для простоты понимания реализованы только следующие методы:
Для объекта запроса simpleKoa реализуйтеquery
Метод чтения может считывать параметры в URL-адресе и возвращать объект.
Для объекта ответа simpleKoa реализуйтеstatus
Методы чтения и записи, предназначенные для чтения и установки кода состояния ответа http, иbody
метод построения возвращаемой информации.
Объект контекста simpleKoa монтирует объекты запроса и ответа и проксирует некоторые распространенные методы.
Сначала создайте request.js:
// request.js
let url = require('url');
module.exports = {
get query() {
return url.parse(this.req.url, true).query;
}
};
Очень просто — экспортировать объект, содержащий метод чтения запроса, черезurl.parse
Метод анализирует параметры в URL-адресе и возвращает его как объект. Следует отметить, что в кодеthis.req
Представляет собственный объект запроса узла,this.req.url
Это метод получения URL-адреса в собственном запросе узла. Когда мы позже изменим application.js, этот req будет смонтирован для объекта запроса koa.
Затем создайте response.js:
// response.js
module.exports = {
get body() {
return this._body;
},
/**
* 设置返回给客户端的body内容
*
* @param {mixed} data body内容
*/
set body(data) {
this._body = data;
},
get status() {
return this.res.statusCode;
},
/**
* 设置返回给客户端的stausCode
*
* @param {number} statusCode 状态码
*/
set status(statusCode) {
if (typeof statusCode !== 'number') {
throw new Error('statusCode must be a number!');
}
this.res.statusCode = statusCode;
}
};
Тоже очень просто.status
Методы чтения и записи устанавливаются или читаются соответственно.this.res.statusCode
. Точно так же этоthis.res
Это собственный объект ответа смонтированного узла. иbody
Методы чтения и записи соответственно устанавливают и читают файл с именемthis._body
характеристики. Здесь нет прямого вызова при установке телаthis.res.end
Чтобы вернуть информацию, это с учетом того, что в koa мы можем вызывать метод тела ответа несколько раз, чтобы переопределить данные настройки. Фактическая операция возврата сообщения будет существовать в application.js.
Затем мы создаем файл context.js и конструируем прототип объекта контекста:
// context.js
module.exports = {
get query() {
return this.request.query;
},
get body() {
return this.response.body;
},
set body(data) {
this.response.body = data;
},
get status() {
return this.response.status;
},
set status(statusCode) {
this.response.status = statusCode;
}
};
Видно, что это в основном прокси для некоторых распространенных методов, черезcontext.query
Действуя напрямуюcontext.request.query
,context.body
иcontext.status
проксиcontext.response.body
иcontext.response.status
. иcontext.request
,context.response
Он будет смонтирован в application.js.
Поскольку определение объекта контекста относительно простое и стандартизированное, при реализации большего количества прокси-методов явно глупо объявлять их один за другим.__defineSetter__
и__defineSetter__
реализовать. С этой целью мы упростили описанный выше метод реализации context.js.Упрощенная версия выглядит следующим образом:
let proto = {};
// 为proto名为property的属性设置setter
function delegateSet(property, name) {
proto.__defineSetter__(name, function (val) {
this[property][name] = val;
});
}
// 为proto名为property的属性设置getter
function delegateGet(property, name) {
proto.__defineGetter__(name, function () {
return this[property][name];
});
}
// 定义request中要代理的setter和getter
let requestSet = [];
let requestGet = ['query'];
// 定义response中要代理的setter和getter
let responseSet = ['body', 'status'];
let responseGet = responseSet;
requestSet.forEach(ele => {
delegateSet('request', ele);
});
requestGet.forEach(ele => {
delegateGet('request', ele);
});
responseSet.forEach(ele => {
delegateSet('response', ele);
});
responseGet.forEach(ele => {
delegateGet('response', ele);
});
module.exports = proto;
Таким образом, когда мы хотим проксировать больше методов запроса и ответа, мы можем напрямую добавить имя метода в массив requestGet/requestSet/responseGet/responseSet (при условии, что он реализован в запросе и ответе).
Наконец, давайте изменим application.js для создания объектов запроса, ответа и контекста на основе трех прототипов объектов:
// application.js
let http = require('http');
let context = require('./context');
let request = require('./request');
let response = require('./response');
class Application {
/**
* 构造函数
*/
constructor() {
this.callbackFunc;
this.context = context;
this.request = request;
this.response = response;
}
/**
* 开启http server并传入callback
*/
listen(...args) {
let server = http.createServer(this.callback());
server.listen(...args);
}
/**
* 挂载回调函数
* @param {Function} fn 回调处理函数
*/
use(fn) {
this.callbackFunc = fn;
}
/**
* 获取http server所需的callback函数
* @return {Function} fn
*/
callback() {
return (req, res) => {
let ctx = this.createContext(req, res);
let respond = () => this.responseBody(ctx);
this.callbackFunc(ctx).then(respond);
};
}
/**
* 构造ctx
* @param {Object} req node req实例
* @param {Object} res node res实例
* @return {Object} ctx实例
*/
createContext(req, res) {
// 针对每个请求,都要创建ctx对象
let 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;
}
/**
* 对客户端消息进行回复
* @param {Object} ctx ctx实例
*/
responseBody(ctx) {
let content = ctx.body;
if (typeof content === 'string') {
ctx.res.end(content);
}
else if (typeof content === 'object') {
ctx.res.end(JSON.stringify(content));
}
}
}
Как видите, самое главное — добавить метод createContext, основанный на том, что мы создали ранееcontext
в качестве прототипа использоватьObject.create(this.context)
метод созданctx
, а так же поObject.create(this.request)
иObject.create(this.response)
Создал объект запрос/ответ и повесил на него трубкуctx
над объектом. Кроме того, объект req/res собственного узла также монтируется вctx.request.req/ctx.req
иctx.response.res/ctx.res
на объекте.
оглянуться назад на нашу предыдущуюcontext/request/response.js
файл, вы можете знать, что использовалось в то времяthis.res
илиthis.response
Откуда это взялось, оказалось примонтировано к соответствующему инстансу в методе createContext. Диаграмма, иллюстрирующая взаимосвязь:
Создается контекст времени выполненияctx
После этого нашapp.use
Параметры функции обратного вызова основаны наctx
.
На следующем рисунке показана структура и отношения наследования объекта ctx:
Наконец вспомним нашуctx.body
Метод не возвращает тело сообщения напрямую, а сохраняет сообщение в свойстве переменной. Чтобы возвращать сообщение каждый раз, когда функция обратного вызова завершает обработку, мы создаемresponseBody
метод, основная функция которого заключается в передачеctx.body
Прочтите сохраненное сообщение, затем позвонитеctx.res.end
Верните сообщение и закройте соединение. Из метода мы знаем, что наше тело сообщения может быть строкой или объектом (который будет сериализован и возвращен как строка). Обратите внимание, что вызов этого метода вызывается после завершения функции обратного вызова, а наша функция обратного вызова является асинхронной функцией, которая возвращает объект Promise после его выполнения, поэтому нам нужно только передать.then
Метод вызывает наш responseBody, которыйthis.callbackFunc(ctx).then(respond)
значение.
Тогда давайте пока протестируем фреймворк. Измените example.js следующим образом:
let simpleKoa = require('./application');
let app = new simpleKoa();
app.use(async ctx => {
ctx.body = 'hello ' + ctx.query.name;
});
app.listen(3000, () => {
console.log('listening on 3000');
});
Видно, что в это время мы проходимapp.use
Входящий уже не роднойfunction (req, res)
Функция обратного вызова, но асинхронная функция в koa2, получает ctx в качестве параметра. Для тестирования зайдите в браузереlocalhost:3000?name=tom
, вы можете видеть, что возвращается «привет, Том», как и ожидалось.
Вставьте и проанализируйте здесь другое понятие знания. Из реализации только что мы знаемthis.context
это контекст в нашем промежуточном программном обеспеченииctx
Прототип объекта. Поэтому в реальной разработке мы можем смонтировать некоторые часто используемые методы дляthis.context
выше, вот так, в промежуточном программном обеспеченииctx
, мы также можем удобно использовать эти методы.Эта концепция называется расширением ctx.Примером является то, что фреймворк Али egg.js интегрировал этот механизм расширения как часть разработки фреймворка.
Ниже показан пример, мы пишемechoData
Метод как расширение, передавая errno, data, errmsg, может возвращать клиенту результаты структурированного сообщения:
let SimpleKoa = require('./application');
let app = new SimpleKoa();
// 对ctx进行扩展
app.context.echoData = function (errno = 0, data = null, errmsg = '') {
this.res.setHeader('Content-Type', 'application/json;charset=utf-8');
this.body = {
errno: errno,
data: data,
errmsg: errmsg
};
};
app.use(async ctx => {
let data = {
name: 'tom',
age: 16,
sex: 'male'
}
// 这里使用扩展,方便的返回utf-8格式编码,带有errno和errmsg的消息体
ctx.echoData(0, data, 'success');
});
app.listen(3000, () => {
console.log('listenning on 3000');
});
Основная третья линия: механизм промежуточного программного обеспечения
На данный момент мы успешно инкапсулировали http-сервер и создали объекты контекста, запроса и ответа. Но не реализована самая главная основная линия, то есть промежуточный механизм koa.
Что касается модели выполнения промежуточного программного обеспечения koa, то koa 1 использует метод выполнения генератор + co.js, а koa 2 использует async/await. Что касается принципа промежуточного программного обеспечения в koa 1, я написал статью, чтобы объяснить, пожалуйста, переместите:Углубленный анализ управления процессами промежуточного программного обеспечения koa
То, что мы здесь реализуем, основано на koa 2, так что опишем принцип еще раз. Для простоты понимания предположим, что у нас есть 3 асинхронные функции:
async function m1(next) {
console.log('m1');
await next();
}
async function m2(next) {
console.log('m2');
await next();
}
async function m3() {
console.log('m3');
}
Мы надеемся, что сможем построить функцию, результатом которой будет последовательное выполнение трех функций. Сначала учтите, что после выполнения m2await next()
Очевидно, что для выполнения функции m3 необходимо построить следующую функцию, функция должна вызвать m3, а затем передать ее в качестве параметра m2.
let next1 = async function () {
await m3();
}
m2(next1);
// 输出:m2,m3
Кроме того, рассмотрим запуск выполнения с m1, тогда следующим параметром m1 должна быть функция, которая выполняет m2, а параметр, переданный m2, — это m3, который моделируется ниже:
let next1 = async function () {
await m3();
}
let next2 = async function () {
await m2(next1);
}
m1(next2);
// 输出:m1,m2,m3
Итак, для n асинхронных функций я хочу, чтобы они выполнялись по порядку? Как видите, процесс генерации nextn можно абстрагировать как функцию:
function createNext(middleware, oldNext) {
return async function () {
await middleware(oldNext);
}
}
let next1 = createNext(m3, null);
let next2 = createNext(m2, next1);
let next3 = createNext(m1, next2);
next3();
// 输出m1, m2, m3
Дальнейшая оптимизация:
let middlewares = [m1, m2, m3];
let len = middlewares.length;
// 最后一个中间件的next设置为一个立即resolve的promise函数
let next = async function () {
return Promise.resolve();
}
for (let i = len - 1; i >= 0; i--) {
next = createNext(middlewares[i], next);
}
next();
// 输出m1, m2, m3
Пока у нас тоже есть идея реализации механизма промежуточного ПО koa, новый application.js выглядит следующим образом:
/**
* @file simpleKoa application对象
*/
let http = require('http');
let context = require('./context');
let request = require('./request');
let response = require('.//response');
class Application {
/**
* 构造函数
*/
constructor() {
this.middlewares = [];
this.context = context;
this.request = request;
this.response = response;
}
// ...省略中间
/**
* 中间件挂载
* @param {Function} middleware 中间件函数
*/
use(middleware) {
this.middlewares.push(middleware);
}
/**
* 中间件合并方法,将中间件数组合并为一个中间件
* @return {Function}
*/
compose() {
// 将middlewares合并为一个函数,该函数接收一个ctx对象
return async ctx => {
function createNext(middleware, oldNext) {
return async () => {
await middleware(ctx, oldNext);
}
}
let len = this.middlewares.length;
let next = async () => {
return Promise.resolve();
};
for (let i = len - 1; i >= 0; i--) {
let currentMiddleware = this.middlewares[i];
next = createNext(currentMiddleware, next);
}
await next();
};
}
/**
* 获取http server所需的callback函数
* @return {Function} fn
*/
callback() {
return (req, res) => {
let ctx = this.createContext(req, res);
let respond = () => this.responseBody(ctx);
let fn = this.compose();
return fn(ctx).then(respond);
};
}
// ...省略后面
}
module.exports = Application;
Как видите, в первую очередьapp.use
Для дооснащения каждый звонокapp.use
, кthis.middlewares
Вставьте функцию обратного вызова. Затем добавляется метод compose(), который использует принцип нашего предыдущего анализа для сборки функций в массиве промежуточного программного обеспечения и возвращает окончательную функцию. Наконец, в методе callback() вызовите compose(), чтобы получить окончательную функцию обратного вызова и выполнить ее.
Перепишите example.js, чтобы проверить механизм промежуточного программного обеспечения:
let simpleKoa = require('./application');
let app = new simpleKoa();
let responseData = {};
app.use(async (ctx, next) => {
responseData.name = 'tom';
await next();
ctx.body = responseData;
});
app.use(async (ctx, next) => {
responseData.age = 16;
await next();
});
app.use(async ctx => {
responseData.sex = 'male';
});
app.listen(3000, () => {
console.log('listening on 3000');
});
// 返回{ name: "tom", age: 16, sex: "male"}
В примере есть три промежуточных ПО, которые соответственно добавляют атрибуты имени, возраста и пола к responseData и, наконец, возвращают данные.
Пока что фреймворк koa в основном всплыл, но нам еще нужно проанализировать последнюю основную строчку: обработку ошибок.
Основная четвертая строка: обработка ошибок
Надежный фреймворк должен гарантировать, что в случае ошибки он сможет перехватить ошибку и вернуть клиенту запасной план. Но очевидно, что наш фреймворк пока не может этого сделать.Предположим, мы модифицируем пример, один из наших промежуточных программ выдает исключение при возникновении ошибки:
let simpleKoa = require('./application');
let app = new simpleKoa();
let responseData = {};
app.use(async (ctx, next) => {
responseData.name = 'tom';
await next();
ctx.body = responseData;
});
app.use(async (ctx, next) => {
responseData.age = 16;
await next();
});
app.use(async ctx => {
responseData.sex = 'male';
// 这里发生了错误,抛出了异常
throw new Error('oooops');
});
app.listen(3000, () => {
console.log('listening on 3000');
});
В настоящее время при доступе к браузеру не будет никакого ответа, потому что исключение не перехвачено нашей структурой и не понижено. Просмотрите код выполнения промежуточного ПО в нашем application.js:
// application.js
// ...
callback() {
return (req, res) => {
let ctx = this.createContext(req, res);
let respond = () => this.responseBody(ctx);
let fn = this.compose();
return fn(ctx).then(respond);
};
}
// ...
Среди них мы знаем, что fn — это асинхронная функция, которая возвращает обещание после выполнения.Помните, что такое обработка ошибок обещания? Правильно, нам нужно только определить функцию onerror, которая выполняет откат при возникновении ошибки, а затем сослаться на эту функцию в методе catch обещания.
В то же время, оглядываясь на фреймворк koa, мы знаем, что при возникновении ошибки объект приложения может пройтиapp.on('error', callback)
Подписка на события ошибок, которая помогает нам обрабатывать ошибки несколькими способами, например распечатывать журналы. Для этого нам также нужно преобразовать объект Application, позволить ему наследовать объект событий в nodejs, а затем сгенерировать событие ошибки в методе onerror. Преобразованный application.js выглядит следующим образом:
/**
* @file simpleKoa application对象
*/
let EventEmitter = require('events');
let http = require('http');
let context = require('./context');
let request = require('./request');
let response = require('./response');
class Application extends EventEmitter {
/**
* 构造函数
*/
constructor() {
super();
this.middlewares = [];
this.context = context;
this.request = request;
this.response = response;
}
// ...
/**
* 获取http server所需的callback函数
* @return {Function} fn
*/
callback() {
return (req, res) => {
let ctx = this.createContext(req, res);
let respond = () => this.responseBody(ctx);
let onerror = (err) => this.onerror(err, ctx);
let fn = this.compose();
// 在这里catch异常,调用onerror方法处理异常
return fn(ctx).then(respond).catch(onerror);
};
}
// ...
/**
* 错误处理
* @param {Object} err Error对象
* @param {Object} ctx ctx实例
*/
onerror(err, ctx) {
if (err.code === 'ENOENT') {
ctx.status = 404;
}
else {
ctx.status = 500;
}
let msg = err.message || 'Internal error';
ctx.res.end(msg);
// 触发error事件
this.emit('error', err);
}
}
module.exports = Application;
Можно видеть, что обработка исключения в методе onerror в основном предназначена для получения кода состояния исключения.Когда err.code равен «ENOENT», заголовок возвращаемого сообщения устанавливается равным 404, в противном случае по умолчанию устанавливается значение 500, и тогда для тела сообщения устанавливается значение "Сообщение об ошибке", если свойство сообщения в исключении пусто, для тела сообщения по умолчанию устанавливается значение "Внутренняя ошибка". звонить послеctx.res.end
Возвращает сообщение, которое гарантирует, что клиент может получить возвращаемое значение даже в исключительных обстоятельствах. наконец прошлоthis.emit
Инициировать событие ошибки.
Затем мы пишем пример для проверки обработки ошибок:
let simpleKoa = require('./application');
let app = new simpleKoa();
app.use(async ctx => {
throw new Error('ooops');
});
app.on('error', (err) => {
console.log(err.stack);
});
app.listen(3000, () => {
console.log('listening on 3000');
});
Когда браузер получает доступ к «localhost: 3000», он возвращает «ooops», а код состояния http — 500. В то же время app.on('error') подписывается на событие исключения и выводит информацию о стеке ошибок в функции обратного вызова.
Еще немного об обработке ошибок здесь. Несмотря на то, что механизм обработки ошибок встроен в koa, при реальном развитии бизнеса мы часто хотим иметь возможность настраивать метод обработки ошибок.В настоящее время лучшим способом является добавление промежуточного программного обеспечения для захвата ошибок в начале, настроить его в соответствии с обработкой ошибок, например:
// 错误处理中间件
app.use(async (ctx, next) => {
try {
await next();
}
catch (err) {
// 在这里进行定制化的错误处理
}
});
// ...其他中间件
На данный момент мы полностью реализовали облегченную версию фреймворка koa.
Эпилог
Полный базовый адрес кода simpleKoa:simpleKoa, который также поставляется с некоторыми примерами.
Поняв принцип реализации этой облегченной версии koa, читатели могут также посмотреть исходный код koa, и они обнаружат, что механизм очень похож на реализованный нами фреймворк, за исключением некоторых дополнительных деталей, например, контекста. /request полного коа Есть более полезные методы, смонтированные на методе /response, или во многих методах лучше отказоустойчивость и т.д. Подробности в этой статье обсуждаться не будут, оставив это на усмотрение заинтересованных читателей~.