[Из песочницы] Свой веб-сервер на NodeJS, и ни единого фреймворка

Для многих людей JavaScript ассоциативно связан с обилием разнообразных фреймворков и библиотек. Разумеется, инструменты, которые помогают нам каждый день — это хорошо, но, мне кажется, нужно искать некий баланс между использованием инструментов и прокрастинацией, а также знать, как работают вещи, которыми ты пользуешься. Поэтому, когда я только сел разбираться с NodeJS, мне было особенно интересно написать полноценный веб-сервер, которым я мог бы пользоваться сам.

Новичку в NodeJS действительно может быть нелегко. JS — один из языков, в котором часто не существует единственного правильного решения конкретной задачи, а добавленные в ноду модули для работы с файловой системой, http сервером и прочими вещами, характерными для работы на сервере, затрудняют переход даже тем, кто пишет хороший код для браузеров. Тем не менее, я надеюсь, что вы знаете основы этого языка и его работы в серверном окружении, если нет, советую посмотреть замечательный скринкаст, который поможет разобраться в основах. И последнее — я не претендую на какой-то исключительно правильный код и буду рад услышать критику — мы все учимся, и это отличный способ получать знания.

Начнём с архитектуры


Исходная папка nodejs хранится на сервере по пути /var/www/html/. В ней и будет наш веб-сервер. Дальше всё просто: создаём в ней директорию routing, в которой будет лежать наш скрипт index.js, а также 4 папки — dynamic, static, nopage и main — для динамически генерируемых страниц, статики, страницы 404 и главной страницы. Выглядит всё это так:
nodejs
--routing
----dynamic
----nopage
----static
----main
----index.js

Создаём наш сервер


Отлично, с архитектурой более-менее определились. Теперь создаём в исходной папке файл server.js со следующим содержимым:
// server.js
// Для начала установим зависимости.
const http = require('http');
const routing = require('./routing');


let server = new http.Server(function(req, res) {
  // API сервера будет принимать только POST-запросы и только JSON, так что записываем
  // всю нашу полученную информацию в переменную jsonString
  var jsonString = '';
  res.setHeader('Content-Type', 'application/json');
  req.on('data', (data) => { // Пришла информация - записали.
      jsonString += data;
  });

  req.on('end', () => {// Информации больше нет - передаём её дальше.
      routing.define(req, res, jsonString); // Функцию define мы ещё не создали.
  });
});
server.listen(8000, 'localhost');

Здорово! Теперь наш сервер будет принимать запросы, записывать JSON-данные, если они есть, но пока что будет вылетать с ошибкой, потому что у нас нет функции define в /routing/index.js. Время это исправить.
// /routing/index.js
const define = function(req, res, postData) {
  res.end('Hello, Habrahabr!');
}
exports.define = define;

Запускаем наш сервер:
node server.js

Заходим туда, где он слушает запросы. Если вы не меняли код, это будет localhost:8000. Ура. Ответ есть.

image
Замечательно. Только это не совсем то, что нам нужно от сервера, правда?

Ловим запросы к нашим API


Да, мы получили ответ, но пока что не слишком близки к конечной цели. Самое время писать логику для нашего роутера.
// /routing/index.js
// Для начала установим зависимости.
const url = require('url');
const fs = require('fs');

const define = function(req, res, postData) {
  // Теперь получаем наш адрес. Если мы переходим на localhost:3000/test, то path будет 'test'
  const urlParsed = url.parse(req.url, true);
    let path = urlParsed.pathname.substr(1);

    // Теперь записываем полный путь к server.js. Мне это особенно нужно, так как сервер будет
    // висеть в systemd, и путь, о котором он будет думать, будет /etc/systemd/system/...
    prePath = `/var/www/html/nodejs/`;
    try {
      // Здесь мы пытаемся подключить модуль по ссылке. Если мы переходим на
      // localhost:8000/api, то скрипт идёт по пути /routing/dynamic/api, и, если находит там
      // index.js, берет его. Я знаю, что использовать тут try/catch не слишком правильно, и потом
      // переделаю через fs.readFile, но пока у вас не загруженный проект, разницу в скорости
      // вы не заметите.
      let dynPath = './dynamic/' + path;
      let routeDestination = require(dynPath);
      res.end('We have API!');
    }
    catch (err) {
      // Не нашлось api? Грустно.
      res.end("We don't have API!");
    }
};
exports.define = define;

Готово. Теперь мы можем создать /routing/dynamic/api, и протестировать то, что у нас есть. Я воспользуюсь для этих целей своим готовым скриптом по адресу /dm/shortenUrl.

image

Определяем, есть ли страница


Мы научились находить скрипты, теперь нужно научиться находить статику. Первым делом пойдём в /routing/nopage и создадим там index.html. Просто создайте костяк html-страницы, и сделайте один-единственный заголовок h1 с текстом:»404». После этого возвращаемся в /routing/index.js, но теперь мы сосредоточимся на уже написанном блоке catch:
// /routing/index.js: блок catch
catch (err) {
      // Находим наш путь к статическому файлу и пытаемся его прочитать.
      // Если вы не знаете, что это за '=>', тогда прочитайте про стрелочные функции в es6,
      // очень крутая штука.
      let filePath = prePath+'routing/static/'+path+'/index.html';
      fs.readFile(filePath, 'utf-8', (err, html) => {
        // Если не находим файл, пытаемся загрузить нашу страницу 404 и отдать её.
        // Если находим — отдаём, народ ликует и устраивает пир во имя царя-батюшки.
        if(err) {
          let nopath = '/var/www/html/nodejs/routing/nopage/index.html';
          fs.readFile(nopath, (err , html) => {
            if(!err) {
              res.writeHead(200, {'Content-Type': 'text/html'});
              res.end(html);
            }
            // На всякий случай напишем что-то в этом духе, мало ли, иногда можно случайно
            // удалить что-нибудь и не заметить, но пользователи обязательно заметят.
            else{
              let text = "Something went wrong. Please contact webmaster@forgetable.ru";
              res.end(text);
            }
          });
        }
        else{
          // Нашли файл, отдали, страница загружается.
          res.writeHead(200, {'Content-Type': 'text/html'});
          res.end(html);
        }
      });
    }

Воодушевляет. Теперь мы можем отдавать страницу 404, а так же html-страницы, которые мы добавляем сами в /routing/static. В моём случае страница 404 выглядит так:

image

Пара слов об API


Способ организации скриптов — личное дело каждого. На данный момент код в блоке try у меня такой:
let dynPath = './dynamic/' + path;
      let routeDestination = require(dynPath);
      routeDestination.promise(res,postData,req).then(
        result => {
          res.writeHead(200);
          res.end(result);
          return;
        },
        error => {
          let endMessage = {};
          endMessage.error = 1;
          endMessage.errorName = error;
          res.end(JSON.stringify(endMessage));
          return;
        }
      );

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

Обрабатываем запросы браузера


Теперь мы уже можем пользоваться нашим сервером, и он будет возвращать страницы. Однако, если вы поместите в /routing/static/somepage ту же страницу, которая прекрасно работает, например, на апаче, вы столкнётесь с некоторыми проблемами.

Во-первых, для этого веб-сервера, как и для, наверное, всех в таком роде, нужно иначе задавать ссылки на css/js/img/… файлы. Если вам хочется подключить к странице 404 css-файл и сделать её красивой, то в случае с апачем мы создали бы в той же папке nopage файл style.css и подключили бы его, указав в тэге link следующее: 'href=«style.css»'. Однако, теперь нам нужно писать путь иначе, а именно:»/routing/nopage/style.css».

Во-вторых, даже если мы подключим всё правильно, то ничего не произойдёт, и у нас всё ещё будет голая страница html. И вот тут мы подходим к самой последней части сегодняшней статьи — дополним скрипт, чтобы он ловил и обрабатывал запросы, которые браузер отправляет сам, читая разметку html. Ну и про favicon не забудем — возьмите фавиконку и положите её в корневую директорию нашего сервера.

Итак, переходим опять в /routing/index.js. Теперь мы будем писать код прямо перед try/catch:

// До этого мы уже получили path и prePath. Теперь осталось понять, какие запросы
// мы получаем. Отсеиваем все запросы по точке, так чтобы туда попали только запросы к
// файлам, например: style.css, test.js, song.mp3
if(/[.]/.test(path)) {
      if(path == 'favicon.ico') {
        // Если нужна фавиконка - возвращаем её, путь для неё всегда будет 'favicon.ico'
        // Получается, если добавить в начале prePath, будет: '/var/www/html/nodejs/favicon.ico'.
        // Не забываем про return, чтобы сервер даже не пытался искать файлы дальше.
        let readStream = fs.createReadStream(prePath+path);
        readStream.pipe(res);
        return;
      }
      else{
        // А вот если у нас не иконка, то нам нужно понять, что это за файл, и сделать нужную
        // запись в res.head, чтобы браузер понял, что он получил именно то, что и ожидал.
        // На данный момент мне нужны css, js и mp3 от сервера, так что я заполнил только
        // эти случаи, но, на самом деле, стоит написать отдельный модуль для 
        if(/.mp3/.test(path)) {
          res.writeHead(200, {
            'Content-Type': 'audio/mpeg'
          });
        }
        else if(/.css/.test(path)) {
          res.writeHead(200, {
            'Content-Type': 'text/css'
          });
        }
        else if(/.js/.test(path)) {
          res.writeHead(200, {
            'Content-Type': 'application/javascript'
          });
        }
        // Опять же-таки, отдаём потом серверу и пишем return, чтобы он не шёл дальше.
        let readStream = fs.createReadStream(prePath+path);
        readStream.pipe(res);
        return;
      }
    }

Фух. Всё готово. Теперь можно подключить наш css-файл и увидеть нашу страницу 404 со всеми стилями:

image

Выводы


Ура! Мы сделали свой веб-сервер, который работает, и работает хорошо. Разумеется, это только начало работы над приложением, но самое главное уже готово — на таком веб-сервере можно поднимать любые страницы, он справляется и со статикой, и с динамическим контентом, и роутинг, на мой взгляд, выглядит удобно — достаточно просто положить соответствующий файл в static или dynamic, и он тут же подхватится, и не надо писать роутинг для каждого конкретного случая.

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

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

  • 26 апреля 2017 в 16:03

    –1

    Неплохо, а будет продолжение?
    • 26 апреля 2017 в 16:04

      0

      Спасибо! Да, я очень хотел бы сделать продолжение, есть несколько достаточно занятных идей, сейчас как раз занимаюсь их реализацией.
  • 26 апреля 2017 в 16:36

    0

    Э… и с каких пор понятие «архитектура» стало означать «структура директорий»?

    • 26 апреля 2017 в 16:44

      0

      Да, я действительно допустил неточность, спасибо.
  • 26 апреля 2017 в 16:38

    0

    Прикольный велик) Где то уже видел похожий!
    Ждем свой http, свой fs и свой url? да что там — свой require)))
    , а за динамический require в try блоке я по жизни ваш фанат)
    • 26 апреля 2017 в 16:40

      +1

      Я же не говорю, что написал инструмент, которым кто-либо должен пользоваться. В первую очередь это была интересная задача, и я думаю, есть люди, которым она тоже интересна. По поводу require я объяснил в комментариях в коде, я знаю, что это не идеальная практика. Спасибо за комментарий!
  • 26 апреля 2017 в 16:41

    0

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


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

    • 26 апреля 2017 в 16:44

      0

      Полностью поддерживаю. Я ни в коем случае не призывал и не призываю использовать что-то подобное. На каких-то своих вещах, которые я буду делать, я буду использовать этот сервер, по работе буду использовать Koa, и это абсолютно нормально.
  • 26 апреля 2017 в 16:45

    0

    Еще ошибки, видимые сходу:


    1. Страница 404 отдается с кодом 200. Это так и задумано?
    2. Вместо хардкода в prePath стоило бы использовать __dirname или require.resolve
    • 26 апреля 2017 в 16:48

      0

      Да, действительно, с кодом 404 вышла ошибка, сейчас исправлю.
      __dirname я почему-то пропустил мимо своего взгляда, большое спасибо за совет.

© Habrahabr.ru