[Из песочницы] Свой веб-сервер на 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. Ура. Ответ есть.
Замечательно. Только это не совсем то, что нам нужно от сервера, правда?
Ловим запросы к нашим 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.Определяем, есть ли страница
Мы научились находить скрипты, теперь нужно научиться находить статику. Первым делом пойдём в
/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 выглядит так: Пара слов об 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 со всеми стилями:
Выводы
Ура! Мы сделали свой веб-сервер, который работает, и работает хорошо. Разумеется, это только начало работы над приложением, но самое главное уже готово — на таком веб-сервере можно поднимать любые страницы, он справляется и со статикой, и с динамическим контентом, и роутинг, на мой взгляд, выглядит удобно — достаточно просто положить соответствующий файл в 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↑
↓
Еще ошибки, видимые сходу:
- Страница 404 отдается с кодом 200. Это так и задумано?
- Вместо хардкода в prePath стоило бы использовать __dirname или require.resolve
26 апреля 2017 в 16:48
0↑
↓
Да, действительно, с кодом 404 вышла ошибка, сейчас исправлю.
__dirname я почему-то пропустил мимо своего взгляда, большое спасибо за совет.