Если вы занимались разработкой для платформы 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 /
. Если не вдаваться в подробности, то можно выделить четыре стадии происходящего, которые мы можем проанализировать:
- Создание нового приложения express.
- Создание нового маршрута.
- Запуск HTTP-сервера на заданном номере порта.
- Обработка поступающих к серверу запросов.
Создание нового приложения 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
:
- В маршрутизаторе приложения (
this._router
) создаётся маршрут. - Метод маршрута
dispatch
назначается в качестве метода-обработчика соответствующего объектаLayer
, и этот объект помещают в стек маршрутизатора. - Обработчик запроса передаётся объекту
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?
Автор: ru_vds