Если вы занимались разработкой для платформы node.js, то вы, наверняка, слышали об express.js. Это — один из самых популярных легковесных фреймворков, используемых при создании веб-приложений для node.



Автор материала, перевод которого мы сегодня публикуем, предлагает изучить особенности внутреннего устройства фреймворка express через анализ его исходного кода и рассмотрение примера его использования. Он полагает, что изучение механизмов, лежащих в основе популярных опенсорсных библиотек, способствует более глубокому их пониманию, снимает с них завесу «таинственности» и помогает создавать более качественные приложения на их основе.

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

Базовый пример использования express


Для начала взглянем на традиционный в деле освоения новых компьютерных технологий «Hello World!»-пример. Его можно найти на официальном сайте фреймворка, он послужит отправной точкой в наших исследованиях.

const express = require('express')
const app = express()

app.get('/', (req, res) => res.send('Hello World!'))

app.listen(3000, () => console.log('Example app listening on port 3000!'))

Этот код запускает новый HTTP-сервер на порту 3000 и отправляет ответ Hello World! на запросы, поступающие по маршруту GET /. Если не вдаваться в подробности, то можно выделить четыре стадии происходящего, которые мы можем проанализировать:

  1. Создание нового приложения express.
  2. Создание нового маршрута.
  3. Запуск HTTP-сервера на заданном номере порта.
  4. Обработка поступающих к серверу запросов.

Создание нового приложения express


Команда var app = express() позволяет создать новое приложение express. Функция createApplication из файла lib/express.js является функцией, экспортируемой по умолчанию, именно к ней мы обращаемся, выполняя вызов функции express(). Вот некоторые важные вещи, на которые тут стоит обратить внимание:

// ...
var mixin = require('merge-descriptors');
var proto = require('./application');

// ...

function createApplication() {
  // Это возвращаемая переменная приложения, о которой мы поговорим позже.
  // Обратите внимание на сигнатуру функции: `function(req, res, next)`
  var app = function(req, res, next) {
    app.handle(req, res, next);
  };

  // ...

  // Функция `mixin` назначает все методы `proto` методам `app`
  // Один из этих методов - метод `get`, который был использован в примере.
  mixin(app, proto, false);

 // ...

  return app;
}

Объект app, возвращённый из этой функции  — это один из объектов, используемых в коде нашего приложения. Метод app.get добавляется с использованием функции mixin библиотеки merge-descriptors, которая ответственна за назначение app методов, объявленных в proto. Сам объект proto импортируется из lib/application.js.

Создание нового маршрута


Взглянем теперь на код, который ответственен за создание метода app.get из нашего примера.

var slice = Array.prototype.slice;

// ...
/**
 * Делегирование вызовов `.VERB(...)` `router.VERB(...)`.
 */

// `methods` это массив методов HTTP, (нечто вроде ['get','post',...])
methods.forEach(function(method){
  // Это сигнатура метода app.get
  app[method] = function(path){
    // код инициализации

    // создание маршрута для пути внутри маршрутизатора приложения
    var route = this._router.route(path);

    // вызов обработчика со вторым аргументом
    route[method].apply(route, slice.call(arguments, 1));

    // возврат экземпляра `app`, что позволяет объединять вызовы методов в цепочки
    return this;
  };
});

Интересно отметить, что, помимо семантических особенностей, все методы, реализующие действия HTTP, вроде app.get, app.post, app.put и подобных им, в плане функционала, можно считать одинаковыми. Если упростить вышеприведённый код, сведя его к реализации лишь одного метода get, то получится примерно следующее:

app.get = function(path, handler){
  // ...
  var route = this._router.route(path);
  route.get(handler)
  return this
}

Хотя у вышеприведённой функции 2 аргумента, она похожа на функцию app[method] = function(path){...}. Второй аргумент, handler, получают, вызывая slice.call(arguments, 1).

Если в двух словах, то app.<method> просто сохраняет маршрут в маршрутизаторе приложения, используя его метод route, а затем передаёт handler в route.<method>.

Метод маршрутизатора route() объявлен в lib/router/index.js:

// proto - это прототип объявления объекта `_router`
proto.route = function route(path) {
  var route = new Route(path);

  var layer = new Layer(path, {
    sensitive: this.caseSensitive,
    strict: this.strict,
    end: true
  }, route.dispatch.bind(route));

  layer.route = route;

  this.stack.push(layer);
  return route;
};

Неудивительно то, что объявление метода route.get в lib/router/route.js похоже на объявление app.get:

methods.forEach(function (method) {
  Route.prototype[method] = function () {
    // `flatten` конвертирует вложенные массивы, вроде [1,[2,3]], в одномерные массивы
    var handles = flatten(slice.call(arguments));

    for (var i = 0; i < handles.length; i++) {
      var handle = handles[i];
      
      // ...
      // Для каждого обработчика, переданного маршруту, создаётся переменная типа Layer,
      // после чего её помещают в стек маршрутов
      var layer = Layer('/', {}, handle);

      // ...

      this.stack.push(layer);
    }

    return this;
  };
});

У каждого маршрута может быть несколько обработчиков, на основе каждого обработчика конструируется переменная типа Layer, представляющая собой слой обработки данных, которая потом попадает в стек.

Объекты типа Layer


И _router, и route используют объекты типа Layer. Для того чтобы разобраться в сущности такого объекта, посмотрим на его конструктор:

function Layer(path, options, fn) {
  // ...
  this.handle = fn;
  this.regexp = pathRegexp(path, this.keys = [], opts);
  // ...
}

При создании объектов типа Layer им передают путь, некие параметры, и функцию. В случае нашего маршрутизатора этой функцией является route.dispatch (подробнее о ней мы поговорим ниже, в общих чертах, она предназначена для передачи запроса отдельному маршруту). В случае с самим маршрутом, эта функция является функцией-обработчиком, объявленной в коде нашего примера.

У каждого объекта типа Layer есть метод handle_request, который отвечает за выполнение функции, переданной при инициализации объекта.

Вспомним, что происходит при создании маршрута с использованием метода app.get:

  1. В маршрутизаторе приложения (this._router) создаётся маршрут.
  2. Метод маршрута dispatch назначается в качестве метода-обработчика соответствующего объекта Layer, и этот объект помещают в стек маршрутизатора.
  3. Обработчик запроса передаётся объекту Layer в качестве метода-обработчика, и этот объект помещается в стек маршрутов.

В итоге все обработчики хранятся внутри экземпляра app в виде объектов типа Layer, которые находятся внутри стека маршрутов, методы dispatch которых назначены объектам Layer, которые находятся в стеке маршрутизатора:


Объекты типа Layer в стеке маршрутизатора и в стеке маршрутов

Поступающие HTTP-запросы обрабатываются в соответствии с этой логикой. Мы поговорим о них ниже.

Запуск HTTP-сервера


После настройки маршрутов надо запустить сервер. В нашем примере мы обращаемся к методу app.listen, передавая ему в качестве аргументов номер порта и функцию обратного вызова. Для того чтобы понять особенности этого метода, мы можем обратиться к файлу lib/application.js:

app.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

Похоже, что app.listen — это просто обёртка вокруг http.createServer. Такая точка зрения имеет смысл, так как если вспомнить то, о чём мы говорили в самом начале, app — это просто функция с сигнатурой function(req, res, next) {...}, которая совместима с аргументами, необходимыми для http.createServer (сигнатурой этого метода является function (req, res) {...}).

После понимания того, что, в итоге, всё, что даёт нам express.js, может быть сведено к весьма интеллектуальной функции-обработчику, фреймворк выглядит уже не таким сложным и таинственным, как раньше.

Обработка HTTP-запроса


Теперь, когда мы знаем, что app — это всего лишь обработчик запросов, проследим за путём, который проходит HTTP-запрос внутри приложения express. Этот путь ведёт его в объявленный нами обработчик.

Сначала запрос поступает в функцию createApplication (lib/express.js):

var app = function(req, res, next) {
    app.handle(req, res, next);
};

Потом он идёт в метод app.handle (lib/application.js):

app.handle = function handle(req, res, callback) {
  // `this._router` - это место, где мы объявили маршрут, используя `app.get`
  var router = this._router;

  // ... 

  // Запрос попадает в метод `handle`
  router.handle(req, res, done);
};

Метод router.handle объявлен в lib/router/index.js:

proto.handle = function handle(req, res, out) {
  var self = this;
  //...
  // self.stack - это стек, в который были помещены все 
  //объекты Layer (слои обработки данных)
  var stack = self.stack;
  // ...
  next();

  function next(err) {
    // ...
    // Получение имени пути из запроса
    var path = getPathname(req);
    // ...
    var layer;
    var match;
    var route;

    while (match !== true && idx < stack.length) {
      layer = stack[idx++];
      match = matchLayer(layer, path);
      route = layer.route;

      // ...
      if (match !== true) {
        continue;
      }
      // ... ещё некоторые проверки для методов HTTP, заголовков и так далее
    }

   // ... ещё проверки 
   
    // process_params выполняет разбор параметров запросов, в данный момент это не особенно важно
    self.process_params(layer, paramcalled, req, res, function (err) {
      // ...

      if (route) {
        // после окончания разбора параметров вызывается метод `layer.handle_request`
        // он вызывается с передачей ему запроса и функции `next`
        // это означает, что функция `next` будет вызвана снова после того, как завершится обработка данных в текущем слое
        // в результате, когда функция `next` будет вызвана снова, запрос перейдёт к следующему слою
        return layer.handle_request(req, res, next);
      }
      // ...
    });
  }
};

Если описать происходящее в двух словах, то функция router.handle проходится по всем слоям в стеке, до тех пор, пока не найдёт тот, который соответствует пути, заданному в запросе. Затем будет произведён вызов метода слоя handle_request, который выполнит заранее заданную функцию-обработчик. Эта функция-обработчик является методом маршрута dispatch, который объявлен в lib/route/route.js:

Route.prototype.dispatch = function dispatch(req, res, done) {
  var stack = this.stack;
  // ...
  next();

  function next(err) {
    // ...
    var layer = stack[idx++];

    // ... проверки
    layer.handle_request(req, res, next);
    // ...
  }
};

Так же, как и в случае с маршрутизатором, при обработке каждого маршрута осуществляется перебор слоёв, которые есть у этого маршрута, и вызов их методов handle_request, которые выполняют методы-обработчики слоёв. В нашем случае это обработчик запроса, который объявлен в коде приложения.

Здесь, наконец, HTTP-запрос попадает в область кода нашего приложения.


Путь запроса в приложении express

Итоги


Здесь мы рассмотрели лишь основные механизмы библиотеки express.js, те, которые ответственны за работу веб-сервера, но эта библиотека обладает и многими другими возможностями. Мы не останавливались на проверках, которые проходят запросы до поступления их в обработчики, мы не говорили о вспомогательных методах, которые доступны при работе с переменными res и req. И, наконец, мы не затрагивали одну из наиболее мощных возможностей express. Она заключается в использовании промежуточного программного обеспечения, которое может быть направлено на решение практически любых задача — от разбора запросов до реализации полноценной системы аутентификации.

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

Уважаемые читатели! Пользуетесь ли вы express.js?

Комментарии (1)


  1. Sirion
    18.06.2018 07:54

    Пользуюсь. Так вышло, что это наиболее часто используемый мной сервер.