Построчный анализ механизма промежуточного программного обеспечения Koa

Node.js

0. Фон

С момента выпуска фреймворка koa многие коллеги по интерфейсу интерпретировали его исходный код. На Zhihu, Nuggets и Github было много статей о реализации его API, таких как ctx, описание механизма промежуточного программного обеспечения, обработка ошибок и другие детали, но статьи, в которых проводится построчный анализ деталей Механизм промежуточного программного обеспечения все еще относительно Меньше, в этой статье будет использована стратегия подробного построчного анализа для обсуждения деталей механизма промежуточного программного обеспечения Koa.

PS: Этот анализ исходного кода Koa основан на версии 2.7.0.

1. Начните со входа

В большинстве случаев используется Koa, предполагая, что наш демо-файл записи называется app.js.

// app.js
const Koa = require('koa');
const app = new Koa();

Когда require ищет сторонний модуль, он будет искать основное поле файла package.json под модулем. Глядя на файл package.json в каталоге репозитория koa, вы можете видеть, что экспорт, предоставляемый модулем, представляет собой файл application.js в каталоге lib.

{
  "main": "lib/application.js",
}

И экспорт, представленный в файле lib/application

module.exports = class Application extends Emitter {}

Видно, что при ссылке на koa в app.js переменная Koa указывает на класс Application.

2. Как отвечать на запросы

(Студенты, которые уже знают, как Коа отвечает на запросы, могут пропустить этот раздел и сразу перейти к Разделу 3)

Хорошо, теперь добавим немного контента в app.js: прослушиваем порт 3004, печатаем строку лога, возвращаемся

const Koa = require('koa');
const app = new Koa();

const final = (ctx, next) => {
  console.log('Request-Start');
  ctx.body = { text: 'Hello World' };
}

app.use(final);

app.listen(3004);

// 启动app.js,就可以看到返回的结果

В приведенном выше коде реализация ctx.body не является предметом этой статьи, если вы знаете, что его функция заключается в установке данных тела ответа, вы можете

В этом разделе необходимо прояснить два вопроса:

  • Роль app.use — монтировать промежуточное ПО. Что он делает?
  • Роль app.listen — слушать порт, что он делает?

Возвращаясь к файлу lib/application только что, вы можете видеть, что метод use смонтирован в файле Application.

  use(fn) {
    // 类型判断
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    
    // 兼容v1版本的koa
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    // 中间省略部分无关代码
    this.middleware.push(fn);
    return this;
  }

В официальной документации тип промежуточного ПО — это функция, поэтому первая строка метода использования завершает проверку типа параметра.

Во втором фрагменте кода оценивается, является ли это функцией-генератором, и если да, то разработчику сообщается, что промежуточное ПО типа генератора будет отброшено, а тип промежуточного ПО будет преобразован из функции-генератора в обычный через метод convert.

Зачем такой кусок кода? Поскольку в версиях Koa v1 и v0 используется схема асинхронного управления Generator+Promise+Co, промежуточное ПО определяется как функция генератора. Но начиная с версии Koa v2 его асинхронная схема управления стала поддерживать Async/Await, поэтому промежуточное ПО также может использовать обычные функции.

Здесь используются несколько библиотек функций, если вы понимаете их функции и принципы, вы можете проверить их, если вам это интересно (но их чтение не повлияет на ваше понимание следующего содержания)

  • isGeneratorFunction: определение того, является ли функция генератором, включает в себя методы оценки Object.prototype.call, Function.prototype.call, Object.getPrototypeOf и т. д.
  • deprecate: подсказывает, что API скоро станет устаревшим.
  • convert: koa-convert, функция состоит в том, чтобы добавить уровень вложенности функций и использовать Co для автоматического выполнения исходной функции Generator.

Функция последнего фрагмента кода заключается в том, чтобы запихнуть входящую функцию в конец свойства this.middleware, и в конструкторе объекта Application вы можете увидеть такую ​​строчку кода

this.middleware = [];

Он используется для хранения промежуточного программного обеспечения.

Хорошо, промежуточное ПО сохраняется через метод use, так как же его использовать? Давайте поговорим о «механизме запроса-ответа», реализованном Koa в качестве базовых знаний, и рассмотрим только что упомянутый метод app.listen, который также монтируется в классе Application.

  listen(...args) {
    // 略去无关代码
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

очень знакомо, не так ли?

Если вы прочитали любое руководство по началу разработки сервера Node, вы будете знать значение, возвращаемое this.callback(), которое является параметром http.createServer. Его формат должен быть следующим

(req, res) => {
	// Do Sth.
}

То есть это функция, которая принимает объект Request и объект Response в качестве параметров. Хорошо, давайте посмотрим на функцию обратного вызова

  callback() {
    const fn = compose(this.middleware);

    // 省略一些错误处理代码
    const handleRequest = (req, res) => {
      // ctx上下文对象构建代码,对理解响应机制不重要
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

Вы можете видеть, что этот код делает две вещи:

  • Используйте функцию compose для обработки массива ПО промежуточного слоя.
  • Возвратите handleRequest в http.createServer в качестве параметра, поэтому каждый раз, когда отправляется запрос, this.handleRequest будет выполняться внутри.

Реализация compose включает в себя процесс выполнения промежуточного программного обеспечения. Прежде всего помните, что он возвращает функцию, а результатом выполнения функции является объект Promise. Конкретная реализация будет объяснена в следующем разделе. Давайте сначала посмотрим на функцию this.handleRequest.

  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    // 错误处理
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

Этот код делает три вещи:

  • Обработка ошибок: функция onerror
  • onFinished прослушивает завершение выполнения ответа, чтобы выполнить некоторую работу по очистке ресурсов.
  • Выполнение входящего fnMiddleware

Первые два в этой статье пока не обсуждаются, поскольку они не влияют на понимание механизма выполнения промежуточного ПО, поэтому обсуждается только последний.

Что такое fnMiddleware? Оглядываясь назад на процесс анализа только что, мы можем понять, что fnMiddleware — это функция fn, обрабатываемая компоновщиком.

const fn = compose(this.middleware);

Возвращаемым результатом является обещание.После разрешения выполняется функция handleResponse и организуется ответ.

Что ж, на этом анализ механизма ответа завершен (о конкретной реализации ответа в дальнейшем можно пока не заботиться) и вводится процесс выполнения промежуточного ПО.

3. Как выполняется промежуточное ПО

3.1 Основная логика выполнения

Как упоминалось ранее, функция compose обрабатывает this.middleware, который является массивом промежуточного ПО, и возвращает функцию fnMiddleware. Хорошо, давайте посмотрим, что это за сочинение

const compose = require('koa-compose');

оказаться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!')
  }

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

Хорошо, давайте начнем с самого начала.

Сначала проверка типа

  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!')
  }

Проверьте тип массива и тип каждого элемента в массиве (PS: Лично я думаю, что здесь лучше дать подсказку, какой тип промежуточного программного обеспечения неверен)

Затем возвращается функция, которая является функцией fnMiddleware, упомянутой ранее.

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)

    // i表示预期想要执行哪个中间件
    function dispatch (i) {
			// 暂时先省略
    }
  }

Смысл двух параметров fnMiddleware также хорошо понятен, вы можете видеть, где только что выполнялся fnMiddleware:

  • контекст: объект контекста, созданный методом this.createContext в экземпляре объекта Application, представляет контекст запроса, но koa-compose только прозрачно передает его, не имеет значения, если вы не понимаете его в деталях.
  • следующий: в настоящее время не определено, что будет объяснено позже.Он используется для указания последней функции, которая будет выполняться после того, как все промежуточное программное обеспечение будет завершено.

Ну, я только что упомянул, что fnMiddleware будет выполняться каждый раз, когда его запрашивают, поэтому давайте посмотрим на процесс его выполнения.

В первую очередь идентифицируется переменная index, и ее функцию мы увидим, когда будем говорить о функции диспетчеризации — она используется для определения того, «какое промежуточное ПО выполнялось в последний раз».

Во-вторых, при 0 в качестве параметра выполняется диспетчерская функция, и ее код выглядит следующим образом:

   function dispatch (i) {
     
      // 校验预期执行的中间件,其索引是否在已经执行的中间件之后
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
     
      // 通过校验,将「已执行的中间件的索引」标记为新的「预期执行的中间件的索引」
      index = i
     
      // 取预期执行的中间件函数
      let fn = middleware[i]
      
      // 预期执行的中间件索引,已经超出了middleware边界,说明中间件已经全部执行完毕,开始准备执行之前传入的next
      if (i === middleware.length) fn = next
     
      // 没有fn的话,直接返回一个已经reolved的Promise对象
      if (!fn) return Promise.resolve()
      try {
        // 对中间件的执行结果包裹一层Promise.resolve
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }

Неважно, если вы не поняли приведенные выше комментарии, давайте прочитаем их построчно и добавим демо, чтобы понять это.После прочтения построчного анализа уже поздно оглядываться назад.

Сначала поместите демонстрационный код:

const Koa = require('koa');
const app = new Koa();

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  console.log('1-End');
}

const two = (ctx, next) => {
  console.log('2-Start');
  next();
  console.log('2-End');
}

const final = (ctx, next) => {
  console.log('final-Start');
  ctx.body = { text: 'Hello World' };
  next();
  console.log('final-End');
}

app.use(one);
app.use(two);
app.use(final);

app.listen(3004);

Как видите, в этом коде есть три промежуточных ПО, и каждое промежуточное ПО представляет собой синхронный метод, вызывающий следующую функцию.

Только что упоминалось, что первое, что нужно выполнить, это dipatch(i), и i равно 0, а функция переменной i состоит в том, чтобы «определить, какое промежуточное ПО должно быть выполнено», тогда первая строка кода выглядит следующим образом:

if (i <= index) return Promise.reject(new Error('next() called multiple times'))

Он сравнивает "индекс" промежуточного программного обеспечения, которое должно быть выполнено, с "индексом" последнего выполненного промежуточного программного обеспечения, и, если последний больше или равен, выдает ошибку, сообщающую вызывающей стороне, что была выполнена следующая функция. неоднократно.

Что это значит? Возьмем демо только что в качестве примера, если я выполню второе промежуточное ПО, то есть функцию two, то есть индекс равен 1, то я обнаружу, что входящий i равен 1, что означает, что я выполню текущее промежуточное ПО еще раз. , конечно нет. Точно так же, если входящий i равен 0, я должен выполнить одно промежуточное программное обеспечение. Это явно неразумно! Одно промежуточное программное обеспечение уже выполнено, и оно больше не должно выполняться!

Но какая разница, если следующая функция выполняется несколько раз? Пожалуйста, держите этот вопрос открытым и продолжайте читать.

Теперь я равен 0, а индекс равен -1.

index = i
let fn = middleware[i]

Я только что сказал, что index используется для определения того, какое промежуточное ПО выполнялось в последний раз (-1 означает 0), а i используется для определения того, какое промежуточное ПО будет выполнено (0 означает первое).Теперь, когда проверка прошла, это означает, что это действительно следующее промежуточное программное обеспечение, которое выполняется. В это время «выполненный флаг» индекса должен быть изменен, чтобы указать, что «промежуточное программное обеспечение, которое только что было «выполнено», теперь официально выполняется».

И используйте переменную fn, чтобы сохранить промежуточное программное обеспечение, готовое к выполнению.

Следующие две строки кода:

if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()

Текущая переменная i по-прежнему равна 0, длина промежуточного ПО равна 3, а fn является первым промежуточным ПО, поэтому оба предложения не будут выполняться и будут пропущены первыми.

try {
  // 原代码是一行,为了方便理解被我拆成了三行
  const next = dispatch.bind(null, i + 1);
  const fnResult = fn(context, next);
  return Promise.resolve(fnResult);
} catch (err) {
  return Promise.reject(err)
}

Вы можете видеть, что этот код делает три маленькие вещи:

  • Во-первых, определяется следующая функция, привязывается контекст выполнения, а первый параметр равен i+1, что означает, что «следующая функция вот-вот будет выполнена».
  • Второй — выполнить функцию fn в случае, если i равно 0, то есть одно промежуточное ПО
  • Третий — выполнить упаковку Promise для результата выполнения одного промежуточного программного обеспечения, чтобы гарантировать, что возвращаемое значение является объектом Promise, и завершить обработку ошибок.

И мы знаем, что формат одного промежуточного ПО следующий:

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  console.log('1-End');
}

Следовательно, промежуточная выполняется следующей, выполняя эквивалент отправки (1), так что каждая промежуточная функция, переданная следующей переменной, является «промежуточным программным обеспечением, выполняющим поведение» пакета.

Итак, теперь диспетчер запускает второе выполнение, и входящее значение i становится равным 1. Пожалуйста, проанализируйте этот процесс самостоятельно.

И когда final выполняется в середине, в следующем операторе i+1 становится 3.

dispatch.bind(null, i + 1)

Таким образом, если следующая функция будет выполнена в финальном промежуточном программном обеспечении, будет выполнена dispatch(3).

// 上次执行到第3个中间件final,所以index是2, i 是3,校验通过
if (i <= index) return Promise.reject(new Error('next() called multiple times'))

// 改index 为 3
index = i
let fn = middleware[i]
// i为3,middleware长度为3,fn赋值为next,而next是fnMiddleware执行时所传入的第二个参数
if (i === middleware.length) fn = next

// fn是undefined,直接返回Promise
if (!fn) return Promise.resolve()

Следовательно, когда выполняется обратный вызов then, установленный при выполнении fnMiddleware, выполняется все промежуточное ПО.

3.2 Проблема множественных вызовов next

Изменить демо

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  next();
  console.log('1-End');
}

Как упоминалось ранее, next в промежуточном программном обеспечении one эквивалентен dispatch.bind(null, 1), поэтому два вызова next эквивалентны двойному выполнению dispatch(1):

  • При вызове в первый раз: i равно 1, index равен 0, i
  • Когда сделан второй вызов: i равно 1, index равен 1, i

Таким образом, этот уровень i

3.3 Досрочное прекращение

Восстановите одно промежуточное ПО в исходное состояние и измените два промежуточного ПО:

const two = (ctx, next) => {
  console.log('2-Start');
  // next()
  console.log('2-End');
}

Таким образом, в следующем операторе кода dispatch.bind(null, i+1) (i равно 1) передается функции two, но функция two не вызывает ее.

return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

Таким образом, окончательное промежуточное ПО не будет выполнено, поэтому при доступе браузера к серверу будет отображаться ошибка Not Found.

Таким образом, второй параметр промежуточного программного обеспечения в koa фактически представляет право выполнения промежуточного программного обеспечения для следующего промежуточного программного обеспечения.

3.4 Асинхронный механизм

Давайте изменим код, чтобы имитировать асинхронный сценарий.

const one = async (ctx, next) => {
  console.log('1-Start');
  await next();
  console.log('1-End');
}

const final = (ctx, next) => {
  return new Promise(resolve => {
    setTimeout(() => {
      ctx.body = { text: 'Hello World' };
      resolve();
    }, 400);
  })
}

app.use(one);
app.use(final);

Когда одно промежуточное ПО выполняется следующим, то есть когда выполняется dispatch(1)

try {
  // 原代码是一行,为了方便理解被我拆成了三行,i是1,
  const next = dispatch.bind(null, i + 1);
  
  // 这儿的fn是final中间件函数
  const fnResult = fn(context, next);
  // fnResult是个400ms之后状态变成resolved的Promise
  return Promise.resolve(fnResult);
} catch (err) {
  return Promise.reject(err)
}

Следовательно, один процесс выполнения промежуточного программного обеспечения может быть упрощен до следующего псевдокода.

const one = async (ctx, next) => {
  console.log('1-Start');
  await (
    // 这个Promise.resolve是在dispatch(1)中被执行的
    Promise.resolve(
      // 这个Promise是final中间件返回的
      new Promise(resolve => {
        setTimeout(() => {
          ctx.body = { text: 'Hello World' };
          resolve();
        }, 400);
      })
    )
  );
  console.log('1-End');
}

Промис имеет особенность: если параметр, принятый Promise.resolve, также является промисом, то внешний промис будет ждать разрешения внутреннего промиса, прежде чем он будет разрешен. Вы можете взять следующий код и запустить его в консоли браузера, чтобы понять это.

Promise.resolve(new Promise((resolve => {
	setTimeout(() => { 
    console.log('Inner Resolved');
    resolve()
  }, 1000);
})))
  .then(() => { console.log('Out Resolved')})

// 先输出:Inner Resolved
// 后输出:Out Resolved

Возвращаясь к вышеприведенному процессу выполнения промежуточного программного обеспечения, то есть оператору ожидания в середине кода одной функции промежуточного программного обеспечения, он будет ожидать выполнения окончательного промежуточного программного обеспечения, прежде чем продолжить выполнение, и в нем играет метод Promise.resolve. решающую роль.

И это как раз модель промежуточного программного обеспечения, реализация модели лукового кольца.

4. Резюме

Пока могу подытожить характеристики механизма исполнения промежуточного ПО версии v2:

  • Store: Сохраните промежуточное ПО в виде массива.
  • Управление состоянием: все изменения состояния передаются объекту ctx без передачи параметров промежуточному программному обеспечению.
  • Управление процессом: выполнение промежуточного программного обеспечения рекурсивным образом и предоставление права на выполнение следующего промежуточного программного обеспечения исполняемому промежуточному программному обеспечению, то есть модели лукового кольца.
  • Асинхронное решение: оберните возвращаемый промежуточным ПО результат с помощью Promise для поддержки реализации логики Await внутри предыдущего промежуточного ПО.

Таким образом, формат промежуточного программного обеспечения Koa очень однороден.

async function mw(ctx, next){
	// Do sth.
  await next();
  // Do something else
}

Но очевидны и его недостатки: слабая схема управления процессом

В системе Koa, поскольку текущее промежуточное ПО может захватить право выполнения только следующего промежуточного ПО, невозможно динамически определить порядок выполнения промежуточного ПО в соответствии с состоянием во время выполнения.Это можно сделать только с помощью статической маршрутизации или инкапсуляции. некоторые услуги в функции инструмента.И введен в промежуточный файл для решения.


насчет нас

Мы — технологическая группа Ant Insurance Experience из бизнес-группы Ant Financial Insurance (Ханчжоу/Шанхай). Мы молодая команда (без бремени исторического стека технологий), текущий средний возраст 92 года (убрать самый высокий балл 8х лет - лидер команды, убрать самый низкий балл 97 лет - брат стажер). Мы поддерживаем практически весь страховой бизнес Ali Group. В 2018 году созданное нами общее сокровище произвело фурор в страховой отрасли, а в 2019 году мы готовили и реализовывали несколько крупных проектов. Теперь, с быстрым развитием бизнес-группы, команда также быстро расширяется.Приглашаем всех мастеров фронтенда присоединиться к нам~

Мы надеемся, что вы обладаете: прочной технической базой, глубокими знаниями в определенной области (узлы/интерактивный маркетинг/визуализация данных и т. д.); способны быстро и непрерывно учиться в процессе обучения; оптимистичны, веселы, живы и общительны.

Если вы заинтересованы в том, чтобы присоединиться к нам, пожалуйста, отправьте свое резюме по электронной почте: schuzhe.wsz@alipay.com


Автор этой статьи: Ant Insurance-Experience Technology Group-Jianzhen

Адрес Наггетс:Кувалда постоянного тока