Перевод серии статей по основам работы с Node.js, написанной Ником Даггером. Оригинал статей находится на tech.pro.

Во второй части этой серии мы научились делать очень простой маршрутизатор, хранить маршруты в json-файле и при запросе сравнивать их с запрашиваемым URL. Это здорово, и даже работает для простых приложений, но что же делать, если мы хотим передать параметры в маршруте? Вы знаете, что есть запросы, содержащие идентификаторы, например, /news/123.

Однако, прежде чем начать, вернемся к нашему коду и немного почистим его.

Реструктуризация представлений

Для начала, создайте директорию views/ (если вы этого ещё не сделали) и переместите туда HTML-файлы. Это поможет сохранить проект чистым и разделить различные части приложения друг от друга.

Теперь, когда файлы перемещены, необходимо создать класс представления View (внутри файла view.js) и его статический метод render. Это поможет отделить рендер представлений от обработчиков. Такая идеология называется «разделение проблем» и помогает сохранить читаемость и семантику кода.

'use strict'

class View {
    static render(response, path, data) {

    }
}

module.exports = View;

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

Переместим код из обработчиков в только что созданный метод класса. Это будет выглядеть так: (обратите внимание на передачу пути до используемого шаблона)

'use strict'
let fs = require('fs');

class View {
    static render(response, path, data) {
        fs.readFile('views/' + path, { encoding: 'utf8' }, function(error, view) {
            if (!error) {
                response.writeHead(200, { 'Content-Type': 'text/html'});
                response.write(view);
                response.end();
            }
        });
    }
}

module.exports = View;

Круто, теперь у нас есть класс View, который рендерит наше представление. Добавим вызов этого модуля в наш обработчик main.js:

'use strict';

let View = require('../view');

class Main {
    static index(response) {
        View.render(response, 'index.html');
    }
    ...
}

А сейчас, приступаем к самому интересному!

Параметры в маршруте

Это очень простая часть. Для начала мы должны определить маршруты, имеющие параметры. В файле routes.json добавим следующий маршрут:

{
    "/": {
        "handler": "main",
        "action": "index"
    },
    "/foo": {
        "handler": "main",
        "action": "foo"
    },
    "/foo/{id}": {
        "handler": "main",
        "action": "getFoo"
    }
}

Я же говорил, что это очень просто. Дальше мы вытащим параметр с помощью регулярных выражений.

Поиск корректного маршрута

У нас есть параметры в маршрутах и обработать их не так-то просто. Ведь здесь не получится просто их перебрать, как мы делали это раньше.

Мы должны сначала проверить содержит ли маршрут параметры и только затем найти правильный маршрут в файле routes.json.

Во-первых, давайте создадим регулярное выражение, которое вытащит параметры, указанные в маршруте.

let pattern = new RegExp('\{(.*)\}');

И только теперь, когда у нас есть шаблон, мы должны сопоставить его с нашим маршрутом в цикле (чтобы увидеть, есть ли у маршрута параметры):

'use strict';

class Router {
    static find(path, routes) {
        let pattern = new RegExp('\{(.*)\}');
        for (let route in routes) {
            if (route.match(pattern)) {

            } else if (path === route) {
                return routes[route];
            }
        }
        return false;
    }
}

module.exports = Router;

Далее нам нужно разделить маршрут до каждого «каталога» и сравнить его с длиной разделенной версии запрашиваемого пути. Это позволит нам узнать совместим ли запрашиваемый путь с тем, что получен в цикле.

    ...
    if (route.match(pattern)) {
        route = route.split('/');
        if (route.length === path.split('/').length) {

        }
    }
    ...

Когда мы знаем, действительно ли маршрут совместим с нашим путем, нам нужно в цикле перебрать массив маршрута (route) и узнать, какой из «каталогов» является параметром.

...
if (route.match(pattern)) {
    route = route.split('/'), path = path.split('/');
    if (route.length === path.length) {
        for (let i = 0; i < route.length; i++) {
            if (route[i].match(pattern)) {

            }
        }
    }
}
...

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

Давайте проанализируем происходящее:

  • Мы проверяем, содержит ли маршрут параметры
  • Мы проверяем, совместим ли маршрут с запрашиваемым путем
  • Мы в цикле перебираем его «каталоги»
  • Мы проверяем, является ли «каталог» параметром

До сих пор довольно просто.

А что произойдет, если найдется один параметр? Мы должны сохранить его до конца работы. Для этого создадим объект parameters выше цикла.

...
if (route.match(pattern)) {
    route = route.split('/'), path = path.split('/');
    if (route.length === path.length) {
        for (let i = 0; i < route.length; i++) {
            if (route[i].match(pattern)) {
                parameters[route[i].match(pattern).pop()] = path[i];
            }
        }
    }
}
...

Здесь мы используем Array.prototype.pop, потому что каждое совпадение возвращает массив, но нам нужна лишь его последняя часть, которая и будет использоваться в качестве имени параметра.

Что произойдет, если «каталог» не является параметром, и что, если текущий «каталог» не соответствует пути «каталога»? Первый должен продолжить цикл, а последний каталог должен выйти из него (из-за неправильного совпадения).

...
if (route.match(pattern)) {
    route = route.split('/'), path = path.split('/');
    if (route.length === path.length) {
        let parameters = {};
        for (let i = 0; i < route.length; i++) {
            if (route[i].match(pattern)) {
                parameters[route[i].match(pattern).pop()] = path[i];
            } else if (route[i] === path[i]) {
                continue;
            } else {
                break;
            }
        }
    }
}
...

Теперь, когда наш цикл завершился, все еще есть шанс, что все эти вещи были проверены у неправильного маршрута. В данном случае, мы бы не хотели его вернуть, поэтому добавим ещё одну проверку после цикла for:

if (Object.keys(parameters).length) {
    return {
        route: routes[route.join('/')],
        data: parameters
    }
}

Вы также можете заметить, что мы возвращаем объект, содержащий текущий маршрут и извлеченные параметры.

Маршрутизатор должен выглядеть так:

'use strict';

class Router {
    static find(path, routes) {
        let pattern = new RegExp('\{(.*)\}');
        for (let route in routes) {
            if (route.match(pattern)) {
                route = route.split('/'), path = path.split('/');
                if (route.length === path.length) {
                    let parameters = {};
                    for (let i = 0; i < route.length; i++) {
                        if (route[i].match(pattern)) {
                            parameters[route[i].match(pattern).pop()] = path[i];
                        } else if (route[i] === path[i]) {
                            continue;
                        } else {
                            break;
                        }
                    }
                    if (Object.keys(parameters).length) {
                        return {
                            route: routes[route.join('/')],
                            data: parameters
                        }
                    }
                }
            } else if (path === route) {
                return {
                    route: routes[route]
                }
            }
        }
        return false;
    }
}

module.exports = Router;

Давайте резюмировать то, что здесь происходит:

  • Сначала мы проходим циклом по всем маршрутам
  • Проверяем каждый маршрут на наличие параметров
  • Далее мы проверяем, совместим ли маршрут с запрошенным путем
  • Если совместим, то в цикле перебираем его «каталоги»
  • Если «каталог» — параметр, сохраняем и возвращаем его

На самом деле, не так уж и сложно!

Используем данные в обработчике

У нас есть объект, который мы вернули, но что же нам с ним делать? Давайте вернемся к файлу app.js и изменим метод createServer.

Для начала переименуем переменную route в found. Далее нужно изменить handler на require('./handlers/' + found.route.handler).

Теперь, когда мы вызываем обработчик действия/метода, мы должны также передавать данные вместе с объектом ответа.

Все вместе это должно выглядеть так:

...
static createServer(settings) {
    http.createServer(function(request, response) {
        let path = url.parse(request.url).pathname;
        let found = Router.find(path, settings.routes);
        try {
            let handler = require('./handlers/' + found.route.handler);
            handler[found.route.action](response, found.data);
        } catch(e) {
            response.writeHead(500);
            response.end();
        }
    }).listen(settings.port);
}
...

Мы можем получить доступ к нашим параметрам в обработчике. Перейдем к обработчику main.js и добавим метод getFoo, на который ссылается routes.json.

Выведем полученные данные:

class Main {
    ...

    static getFoo(response, data) {
        console.log(data) // { id: ___ }
        View.render(response, 'foo.html', data);
    }
    ...
}

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

Сейчас, по крайней мере, мы можем обрабатывать более сложные GET-запросы путем передачи параметров в наши маршруты. Обрабатывать POST-запросы мы будем уже в четвертой части.

GitHub

Для удобства чтения и рассмотрения кода я собрал весь код из статьи в репозиторий, доступный на GitHub по этой ссылке.

Навигация