Пишем сервер для мобильного приложения на Node.js без необходимости настройки Apache или Nginx.

Что из себя представляет Node.js?

Node.js — это платформа, которая позволяет выполнять JavaScript вне браузера и предоставляет API для операций ввода-вывода. По сути, Node превращает JS в язык общего назначения, позволяя писать на нем практически любые приложения. Тем не менее, наиболее часто эта технология применяется для написания серверной части или разнообразных утилит.

Одной из отличительных черт данной технологии является ее подход к работе с вводом-выводом и отсутствие многопоточности — упоминание неблокирующего, асинхронного API ввода-вывода можно найти чуть ли не в каждом определении. Чтобы понять, о чем речь, приведу простой пример — выполнение HTTP-запроса:

  • С традиционным подходом вы вызываете функцию/метод, совершающую запрос, и выполнение потока блокируется до тех пор, пока не придет ответ или не произойдет ошибка. Если нужно выполнять какие-то действия во время выполнения запроса, то нужно использовать еще один поток.
  • В Node вы вызываете функцию/метод, и помимо параметров запроса, передаете ей еще один аргумент — функцию, которая будет вызвана по завершению запроса. Вызов не блокирует выполнение, и код продолжает выполняться.

На этом принципе в Node построено все взаимодействие с сетью, базами данных и файловой системой. API для многопоточности не предусмотрено, и это убирает целый класс ошибок, связанных с синхронизацией потоков и race condition’ами. «Плата» за это — сложности с распараллеливанием вычислений.

Пару слов об npm

npm — это пакетный менеджер для Node, важная часть экосистемы. Собственно, так и расшифровывается — Node Package Manager (хотя на их сайте есть много забавных альтернативных расшифровок). На момент написания статьи количество пакетов в npm превышает 400 тысяч. Любые библиотеки и фреймворки для Node следует ставить через npm. Как правило, пакеты ставятся локально, т. е. только для данного проекта. Однако, некоторые утилиты, имеющие интерфейс командной строки (CLI), предпочтительно ставить глобально, на уровне системы. Пакеты, которые вы ставите из npm локально, должны быть перечислены в файле package.json, находящемся в корне проекта. Это нужно для того, чтобы вместе с проектом была информация обо всех пакетах, которые ему необходимы (можно считать этот файл аналогом Podfile).

Итак, начнем

Собственно, никаких особых требований к системе и ПО тут нет, открывайте терминал, свой любимый текстовый редактор, и вперед!

Установите Node при помощи Homebrew:

$ brew install node

Альтернативный вариант — можно скачать установщик с официального сайта.

Убедимся, что Node установилась:

$ node --version

Если в ответ вы увидели версию, то этап установки завершен, и можно переходить к созданию приложения.

Создаем проект

Для примера, в данной статье мы рассмотрим простое чат-приложение. Оно будет поддерживать создание чат-комнат, поиск чат-комнат по имени, чтение и отправку сообщений в произвольные комнаты. Чтобы не перегружать статью подробностями, все данные будут храниться в памяти сервера без применения базы данных. Общение между клиентом и сервером будет организовано при помощи протокола HTTP.

Создайте директорию для проекта и перейдите в нее в терминале. Чтобы создать проект, воспользуемся пакетным менеджером npm:

$ npm init

npm попросит ввести несколько значений, но это можно пропустить, нажимая Enter. На работоспособность проекта это не повлияет. Данной командой мы сгенерировали файл package.json, в котором содержится информация о приложении. Любые поля в нем можно поменять позднее.

Установим зависимости

Для написания простого сервера нам понадобится всего два внешних пакета — это фреймворк Express и модуль body-parser к нему, который разбирает JSON-тело POST-запросов. Несмотря на то, что Express гордо называется фреймворком, никаких сложностей с его освоением не возникнет — во всяком случае, он точно проще, чем использование встроенного в Node HTTP-сервера в чистом виде. Express, по сути, является оберткой над ним.

Установим зависимости следующей командой:

$ npm install --save --exact express body-parser

Данная команда ставит пакеты локально, в каталог node_modules, куда ставятся все зависимости. Также, после выполнения команды, в файле package.json добавятся следующие строки:

"dependencies": {
  "body-parser": "1.15.2",
  "express": "4.14.0"
}

Можно было бы просто выполнить «npm install express», и это бы сработало, но в package.json не записалась бы информация о новых зависимостях. Поэтому в этой команде добавлены флаги —save и —exact.

  • —save указывает сохранить зависимость в package.json, что и произошло.
  • —exact заставит npm указать точную версию зависимости. Без этого флага будет указана минимальная допустимая версия, т. е. возможны обновления пакета без изменения версии в package.json. К сожалению, в модулях из npm, как и в любом ПО, встречаются баги. Можно потратить довольно много времени из-за поломки проекта при обновлении пакета, поэтому я советую использовать —exact.

Пишем код приложения

По возможности я постарался прокомментировать код, но если вы никогда не имели дела с JavaScript, то имеет смысл ознакомиться с основами языка, благо он довольно прост, если не рассматривать тонкие моменты вроде замыканий: Основы JavaScript.

Создадим четыре файла:

  • index.js — точка входа в программу;
  • app.js — задание обработчиков для разных URL;
  • models.js — описание классов, которые будут хранить наши данные;
  • config.json — JSON-файл с конфигурацией приложения.

Начнем с файла models.js, т. е. с определения модели данных нашего приложения. В современном JavaScript можно использовать «традиционный» синтаксис для классов, чем мы и воспользуемся. Однако стоит помнить, что ввиду динамической природы языка, поля явно нигде не определяются, а просто им присваивается значение в конструкторе.

Строго говоря, несмотря на более-менее привычный синтаксис, реализация ООП «под капотом» в JS отличается от большинства языков, здесь класс определяется функцией-конструктором со свойством prototype, в котором описаны его методы. Поэтому класс — это функция, вызов которой с оператором new приводит к созданию нового объекта. Подробности реализации — тема, достойная отдельной статьи, поэтому я просто приведу ссылку на материал по теме: ООП в JavaScript.

Модель данных довольно простая:

  • Существует множество чат-комнат, которыми управляет ChatRoomManager.
  • Каждая чат-комната хранит множество сообщений.

untitled-diagram

Содержимое файла models.js будет таким:

class ChatRoomManager {
    constructor () {
        // Задаем поля класса в его конструкторе.

        // Словарь чат-комнат - позволяет получить чат-комнату по ее id.
        this.chatRooms = {};
        // Счетчик, который хранит id, который бдет присвоен следующей комнате
        this._nextRoomId = 0;
    }

    createRoom (name) {
        // Создаем объект новой комнаты
        let room = new ChatRoom(this._nextRoomId++, name);
        // Заносим его в словарь
        this.chatRooms[room.id] = room;
        return room;
    }

    // Регистронезависимый поиск по имени комнаты
    findByName (searchSubstring) {
        // Переведем поисковый запрос в нижний регистр
        let lowerSearchSubstring = searchSubstring.toLowerCase();

        // Получим массив комнат. Для этого, получим все ключи словаря в виде
        // массива, и для каждого ключа вытащим соответствующий ему элемент
        // Если вы используете Node 7.2 или выше, то можно сделать так:
        // let rooms = Object.values(this.chatRooms);
        let rooms = Object.keys(this.chatRooms).map(id => this.chatRooms[id]);

        // Отфильтруем из массива только те комнаты, в названии которых есть
        // заданная подстрока
        return rooms.filter(room =>
            room.name.toLowerCase().indexOf(lowerSearchSubstring) !== -1
        );
    }

    // Получаем комнату по ее id
    getById (id) {
        return this.chatRooms[id];
    }
}


class ChatRoom {
    constructor (id, name) {
        this.id = id;
        // В отличие от чат-комнат, сообщения хранятся в массиве, а не в словаре,
        // так как не стоит задачи получения сообщения по его id
        this.messages = [];
        this.name = name;
        // По аналогии с ChatRoomManager - счетчик хранит id следующего объекта
        this._nextMessageId = 0;
    }

    postMessage (body, username) {
        // Создадим новый объект сообщения и поместим его в массив
        // Дату намеренно не передаем - см. конструктор Message
        let message = new Message(this._nextMessageId++, body, username);
        this.messages.push(message);
        return message;
    }

    toJson () {
        // Приведем объект к тому JSON-представлению, которое отдается клиенту
        return {
            id: this.id,
            name: this.name
        };
    }
}


class Message {
    constructor (id, body, username, datetime) {
        this.id = id;
        this.body = body;
        this.username = username;
        // Если дата не задана явно, то используются текущие дата и время сервера
        // new Date() без аргументов примет значение текущих даты/времени
        this.datetime = datetime || new Date();
    }

    toJson () {
        return {
            id: this.id,
            body: this.body,
            username: this.username,
            // Объект даты сериализуем в строку
            datetime: this.datetime.toUTCString()
        };
    }
}


// Определим объекты, которые будут экспортироваться модулем как внешнее API:
module.exports = { ChatRoomManager, ChatRoom, Message };

Теперь перейдем к обработке клиентских запросов. Данные между клиентом и сервером будем передавать в формате JSON. Наш сервер будет поддерживать 4 разных запроса:

  • GET /rooms — получение списка комнат, опционально — с фильтрацией по имени. Чтобы фильтровать комнаты по имени, нужно будет передать query-параметр searchString. Отдает список комнат в JSON.
  • POST /rooms — создание комнаты. Принимает JSON-тело с единственным параметром name. Возвращает созданную комнату в JSON.
  • GET /rooms/<roomId>/messages — получение сообщений из комнаты roomId. Отдает список сообщений в виде JSON.
  • POST /rooms/<roomId>/messages — отправка сообщения в комнату roomId. Отдает отправленное сообщение в виде JSON.

Вот как будет выглядеть код для обработки этих запросов (app.js):

// Подключим внешние зависимости из node_modules.
// Каждая библиотека возвращает некоторый объект через module.exports, точно так
// же, как мы это сделали в models.js. Функция require динамически находит
// модуль, исполняет его код и возвращает его module.exports нам.
const express = require('express');
const bodyParser = require('body-parser');

// Подключаем наш модуль models.js
const models = require('./models');


class Application {
    constructor () {
        // Создаем наше Express-приложение.
        this.expressApp = express();
        // Создаем ChatRoomManager, экспортированный из models.js
        this.manager = new models.ChatRoomManager();
        this.attachRoutes();
    }

    attachRoutes () {
        let app = this.expressApp;
        // Создадим middleware для обработки JSON-тел запросов, т. е. функцию,
        // которая будет вызываться перед нашими обработчиками и обрабатывать
        // JSON в теле запроса, чтобы наш обработчик получил готовый объект.
        let jsonParser = bodyParser.json();

        // Назначаем функции-обработчики для GET/POST разных URL. При запросе на
        // указанный первым аргументом адрес, будут вызваны все функции,
        // которые переданы начиная со второго аргумента (их может быть сколько
        // угодно).
        // Важно обратить внимание на .bind - тут мы назначаем в качестве
        // обработчиков методы, а не функции. По сути, метод - это функция,
        // привязанная к объекту, что мы и делаем методом bind. Без него мы
        // получим неопределенный this, так как метод будет вызван как обычная
        // функция. Так следует делать всегда при передаче метода как аргумента.
        // Каждый обработчик принимает два аргумента - объекты запроса и ответа,
        // обозначаемые как req и res.
        app.get('/rooms', this.roomSearchHandler.bind(this));
        app.post('/rooms', jsonParser, this.createRoomHandler.bind(this));
        // Имя после двоеточия - параметр, принимающий произвольное значение.
        // Такие параметры доступны в req.params
        app.get('/rooms/:roomId/messages', this.getMessagesHandler.bind(this));
        app.post('/rooms/:roomId/messages', jsonParser, this.postMessageHandler.bind(this));
    }

    // Обработчик создания комнаты
    createRoomHandler (req, res) {
        // Если нет обязательного поля name в JSON-теле - вернем 400 Bad Request
        if (!req.body.name) {
            res.status(400).json({});
        } else {
            // Создаем комнату в manager'e и вернем ее в виде JSON
            let room = this.manager.createRoom(req.body.name);
            let response = {
                room: room.toJson()
            };
            // Отправим ответ клиенту. Объект будет автоматически сериализован
            // в строку. Если явно не задано иного, HTTP-статус будет 200 OK.
            res.json(response);
        }
    }

    getMessagesHandler (req, res) {
        // Получаем комнату по ID. Если комнаты нет - вернется undefined
        let room = this.manager.getById(req.params.roomId);

        // Проверка на то, нашлась ли такая комната
        if (!room) {
            // Если нет - 404 Not Found и до свидания
            res.status(404).json({});
        } else {
            // Преобразуем все сообщения в JSON
            let messagesJson = room.messages.map(message => message.toJson());
            let response = {
                messages: messagesJson
            };

            // Отправим ответ клиенту
            res.json(response);
        }
    }

    postMessageHandler (req, res) {
        // Получаем комнату по ID
        let room = this.manager.getById(req.params.roomId);

        if (!room) {
            res.status(404).json({});
        } else if (!req.body.body || !req.body.username) {
            // Если формат JSON-сообщения некорректный - вернем 400 Bad Request
            res.status(400).json({});
        } else {
            // Создаем сообщение и возвращаем его клиенту
            let message = room.postMessage(req.body.body, req.body.username);
            let response = {
                message: message.toJson()
            };

            res.json(response);
        }
    }

    roomSearchHandler (req, res) {
        // Получаем строку-фильтр из query-параметра searchString.
        // Если параметр не задан, то используем пустую строку, т. е.
        // будут найдены все комнаты
        let searchString = req.query.searchString || '';
        // Ищем комнаты и представляем их в виде JSON
        let rooms = this.manager.findByName(searchString);
        let roomsJson = rooms.map(room => room.toJson());
        let response = {
            rooms: roomsJson
        };

        // Отдаем найденное клиенту
        res.json(response);
    }
}


// Экспортируем наш класс наружу
module.exports = Application;

Почти готово! Теперь осталось написать модуль, который будет точкой входа и запустить из него HTTP-сервер. Файл index.js:

const Application = require('./app');
// JSON-файлы тоже можно подгружать через require!
const config = require('./config.json');


let app = new Application();
// Возьмем express-приложение и запустим HTTP-сервер. Настройки возьмем из
// конфига приложения. После того, как приложение начнет слушать порт,
// будет выполнена функция, переданная в качестве аргумента.
app.expressApp.listen(config.port, config.host, function() {
    console.log(`App listening at port ${config.port}`);
});

И создадим конфиг (config.json):

{
    "host": "0.0.0.0",
    "port": 8000
}

Готово!

Тестируем

Для начала, запустим наше приложение:

 $ node index.js

В консоль должно напечататься следующее сообщение:

App listening at port 8000

Остановить сервер можно, нажав Ctrl-C.

Теперь откройте еще одно окно терминала. Для тестирования воспользуемся утилитой curl, которая позволяет слать произвольные HTTP-запросы. Для начала, создадим две чат-комнаты двумя POST-запросами:

$ curl -X POST -H "Content-Type: application/json" -d '{"name": "Test room 1"}' http://localhost:8000/rooms
$ curl -X POST -H "Content-Type: application/json" -d '{"name": "Test room 2"}' http://localhost:8000/rooms

Попробуем получить комнаты, в названии которых есть единица:

 $ curl 'http://localhost:8000/rooms?searchString=1'

Такой запрос должен вернуть следующий ответ:

{"rooms":[{"id":0,"name":"Test room 1"}]}

Теперь попробуем написать пару сообщений и тут же получить их из беседы:

 $ curl -X POST -H "Content-Type: application/json" -d '{"body": "Test message", "username": "test_user"}' http://localhost:8000/rooms/0/messages
 $ curl -X POST -H "Content-Type: application/json" -d '{"body": "Еще одно сообщение!", "username": "TheOwl"}' http://localhost:8000/rooms/0/messages
 $ curl 'http://localhost:8000/rooms/0/messages'

Последний запрос должен вернуть такой ответ:

{"messages":[{"id":0,"body":"Test message","username":"test_user","datetime":"Wed,28 Dec 2016 22:49:16 GMT"},{"id":1,"body":"Еще одно сообщение!","username":"TheOwl","datetime":"Wed, 28 Dec 2016 22:49:30 GMT"}]}

Таким образом можно тестировать любые HTTP-запросы, если у вас нет приложения или иного фронтенда к серверу. Но не забывайте, что вся информация хранится в памяти сервера, и после перезапуска будет утеряна.

Заключение

Выше мы рассмотрели создание максимально простого сервера. Конечно же, в реальности все будет несколько сложнее — как минимум, добавится база данных и более сложная логика. Для базы данных можно использовать ORM Sequelize — так вам не придется писать чистый SQL и код будет более читаемым.

Исходники сервера можно взять из нашего репозитория на GitHub. После клонирования репозитория, вам нужно будет выполнить в каталоге сервера команду npm install (без аргументов), так как каталог node_modules в Git не отслеживается (добавлен в .gitignore).

Как и с любой технологией, главное — это практика!

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.