javascript

Свой веб-сервер на NodeJS, и ни единого фреймворка

  • четверг, 27 апреля 2017 г. в 03:14:57
https://habrahabr.ru/post/327440/
  • Node.JS
  • JavaScript


Для многих людей 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;

    // Теперь записываем полный путь к server.js. Мне это особенно нужно, так как сервер будет
    // висеть в systemd, и путь, о котором он будет думать, будет /etc/systemd/system/...
    prePath = __dirname`;
    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+'/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(404, {'Content-Type': 'text/html'});
              res.end(html);
            }
            // На всякий случай напишем что-то в этом духе, мало ли, иногда можно случайно
            // удалить что-нибудь и не заметить, но пользователи обязательно заметят.
            else{
              let text = "Something went wrong. Please contact webmaster@forgetable.ru";
              res.writeHead(404, {'Content-Type': 'text/plain'});
              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 директорию нашего сервера.

Итак, переходим опять в /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/routing/favicon.ico'.
        // Не забываем про return, чтобы сервер даже не пытался искать файлы дальше.
        let readStream = fs.createReadStream(prePath+path);
        readStream.pipe(res);
        return;
      }
      else{
        // А вот если у нас не иконка, то нам нужно понять, что это за файл, и сделать нужную
        // запись в res.head, чтобы браузер понял, что он получил именно то, что и ожидал.
        // На данный момент мне нужны css, js и mp3 от сервера, так что я заполнил только
        // эти случаи, но, на самом деле, стоит написать отдельный модуль для этого.
       if(/[.]mp3$/gi.test(path)) {
          res.writeHead(200, {
            'Content-Type': 'audio/mpeg'
          });
        }
        else if(/[.]css$/gi.test(path)) {
          res.writeHead(200, {
            'Content-Type': 'text/css'
          });
        }
        else if(/[.]js$/gi.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, и он тут же подхватится, и не надо писать роутинг для каждого конкретного случая.

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