Тонкости nodejs. Часть I: пресловутый app.js

  • Tutorial
Я работаю с node.js более трех лет и за это время успел хорошо познакомиться с платформой, ее сильными и слабыми сторонами. За это время платформа сильно изменилась, как, собственно, и сам javascript. Идея использовать одну среду и на сервере и на клиенте пришлась многим по душе. Еще бы! Это удобно и просто! Но, к сожалению, на практике все оказалось не так радужно, вместе с плюсами платформа впитала в себя и минусы используемого языка, а разный подход к реализации практически свел на нет плюсы от использования единой среды. Так все попытки реализовать серверный js до ноды не взлетели, взять тот же Rhino. И, скорее всего, node ждала та же участь, если бы не легендарный V8, неблокирующий код и потрясающая производительность. Именно за это его так любят разработчики. В этой серии статей, я постараюсь рассказать о неочевидных на первый взгляд проблемах и тонкостях работы, с которыми вы столкнетесь в разработке на nodejs.



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

Начать хочется с наиболее часто встречаемой и распространенной реализации приложения – главной точкой входа – app.js, на примере веб-приложения с использованием express. Обычно выглядит она так:

// config.js

exports.port = 8080;

// app.js

var express = require('express');
var config = require('./config.js'); 

var app = express();

app.get('/hello', function(req, res) {
    res.end('Hello world');
});

app.listen(config.port);
На первый взгляд все отлично, код понятный, конфигурация вынесена в отдельный файл и может быть изменена для дева и продакшна. Подобная реализация встречается на всех ресурсах посвященных созданию веб-приложений на nodejs. Вот мы и заложили фундамент нашей ошибки в десяти строках чистейшего кода. Но обо всем по порядку.

И так, мы написали hello world. Но, это чересчур абстрактный пример. Давайте добавим конкретики и напишем приложение которое будет выводить список файлов из указаной директории и отображать содержимое отдельных файлов, запрашивая данные из mongo.

// config.js

exports.port = 8080;
exports.mongoUrl = 'mongodb://localhost:27017/test';

// app.js

var express = require('express');
var MongoClient = require('mongodb').MongoClient;
var config = require('./config.js'); 

// Создаем соединение с базой
var db;
MongoClient.connect(config.mongoUrl, function(err, client){
    if (err) {    
        console.error(err);
        process.exit(1);
    }
    db = client;
});

// Создаем веб-сервер
var app = express();

app.get('/', function(req, res, next) {
    db.collection('files').find({}).toArray(function(err, list){
        if (err) return next(err);

        res.type('text/plain').end(list.map(function(file){
            return file.path;
        }).join('\r'));
    });
});

app.get('/file', function(req, res, next){
    db.collection('files').findOne({path:req.query.file}).toArray(function(err, file){
        if (err) return next(err);

        res.type('text/plain').end(file.content);
    });
});

app.listen(config.port);
Отлично, все просто и наглядно: соединяемся с базой, создаем сервер, назначаем обработчки для путей. Но давайте подумаем, какими недостатками обладает код:

  1. Его тяжело тестировать, так как нет возможности напрямую проверить результат возвращаемый методами.
  2. Его тяжело конфигурировать – невозможно изменить конфигурацию для двух экземпляров приложения.
  3. Компоненты приложения недоступны для внешнего кода, а значит и для расширения.
  4. Ошибки никуда не передаются и должны быть обработаны на месте или же выброшены на самый верхний уровень.

На практике это приводит к монолитному коду. И скорому рефакторингу. Что можно сделать? Необходимо разделить логику и интерфейс.
Все что касается работы приложения давайте оставим в app.js, а все что касается веб-http-интерфейса в http.js.

// app.js

var MongoClient = require('mongodb').MongoClient;
var EventEmitter = require('event').EventEmitter;
var util = require('util');

module.exports = App;

function App(config) {
    var self = this;

    // Инициализируем event emitter
    EventEmitter.call(this);

    MongoClient.connect(config.mongoUrl, function(err, db){
        if (err) return self.emit("error", err);

        self.db = db;
    });


    this.list = function(callback) {
        self.db
        .collection('files')
        .find({})
        .toArray(function(err, files){
            if (err) return callback(err);

            files = files.map(function(file){
                return file.path
            });
            callback(null, files);
        });
    };

    this.file = function(file, callback) {
        self.db
        .collection('files')
        .findOne({path:file})
        .toArray(callback);
    };
}

util.inherits(App, EventEmitter);

// config.js
exports.mongoUrl = "mongo://localhost:27017/test";

exports.http = {
    port : 8080
};

// http.js

var App = require('./app.js');
var express = require('express');

var configPath = process.argv[2] || process.env.APP_CONFIG_PATH || './config.js';
var config = require(configPath);

var app = new App(config);

app.on("error", function(err){
    console.error(err);
    process.exit(1);
});

var server = express();

server.get('/', function(req, res, next){
    app.list(function(err, files){
        if (err) return next(err);

        res.type('text/plain').end(files.join('\n'));
    });
});

server.get('/file', function(req, res, next){
    app.file(req.query.file, function(err, file){
        if (err) return next(err);

        res.type('text/plain').end(file);
    });
});

server.listen(config.http.port);
Что мы сделали? Добавили событийную модель для отлова ошибок. Добавили возможность указывать путь к конфигурации для каждого экземпляра приложения.
Таким образом мы избавились от перечисленых выше проблем:
  1. Любой метод доступен напрямую через объект app.
  2. Управление конфигурацией стало гибким: можно указать путь в консоли или через export APP_CONFIG_PATH=…
  3. Появился централизованный доступ к компонентам.
  4. Ошибки приложения отлавливаются объектом app и могут быть обработаны с учетом контекста.

Теперь мы можем легко добавить новый интерфейс для командной строки:
// cli.js

var App = require('./app.js');
var program = require('commander');

var app;

program
    .version('0.1.0')
    .option('-c, --config <file>', 'Config path', 'config.js', function(configPath){
        var config = require(configPath);

        app = new App(config);

        app.on("error", function(err){
            console.error(err);
            process.exit(1);
        });
    });

program.command('list')
    .description('List files')
    .action(function(){
        app.list(function(err, files){
            if (err) return app.emit("error", err);

            console.log(files.join('\n'));
        });
    });

program.command('print <file>')
    .description('Print file content')
    .action(function(cmd){
        app.file(cmd.file, function(err, file){
            if (err) return app.emit("error", err);

            console.log(file);
        });
    });
или тест
// test.js

var App = require('App');
var app = new App({
    mongoUrl : "mongo://testhost:27017/test"
});

// Пусть тестовая база содержит только один документ:
// {path:'README.md', content:'This is README.md'}

app.on("error", function(err){
    console.error('Test failed', err);
    process.exit(1);
});

// Тест написан для библиотеки nodeunit
module.exports = {
    "Test file list":function(test) {
        app.list(function(err, files){
            test.ok(Array.isArray(files), "Files is an Array.");
            test.equals(files.length, 1, "Files length is 1.");
            test.equals(file[0], "README.md", "Filename is 'README.md'.");
            test.done();
        });
    }
}
Конечно, приложение теперь выглядит не таким минималистичным, как в примерах, зато является боле гибким. В следующей части я расскажу про отлов ошибок.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 20

    0
    Круто! Полезная статья. Как раз искал удобную заготовку.
    +1
    Насколько актуальной для вхождения остаётся эта книга: "Node.js для начинающих"? Что нужно дополнительно к ней (и в объёме по времени), чтобы полноценно работать с Нодой на сервере в области веб-приложений, при условии, что опыт на сервере и с БД есть?

    Я, например, эту книгу почитал в своё время, но мой опыт с Нодой ограничился различными сборочными скриптами, и основной «книгой» были фактичесски «доки» по API. Нельзя сказать, что какое-либо «Введение» решительно помогло. А если идти с Нодой в веб-разработку, то что ещё нужно, какие книги?
      +1
      Данную книгу не читал, поэтому трудно что-то сказать, не зная на чем сделан акцент. Точно могу сказать одно: API ноды изменилось с тех пор, некоторые методы стали не актуальными – могут возникнуть проблемы.
      +2
      Можно пару вопросов?
      1. А что будет если ваш сервер запустится и начнёт отвечать на запросы до того как случится коннект с монгой?
      2. А что будет если коннект с монгой оборвётся на какое-то время?
        0
        1) в MongoClient.connect добавить что то вроде emit(«ready»), и делать server.listen после ready-ивента
        2) всюда будет вылетать в callback(err)
          0
          В первом случае процесс упадет, так как значение переменной db – undefined. Во-втором, он так же упадет из-за ошибки соединения. Как обработать эти ситуации я расскажу позже.
          0
          Спасибо за статью, с нетерпением жду продолжения. Будет ли примеры работы с promise, а то пока не сильно справляюсь с асинхронностью запросов, в частности к DB…
            0
            Про асинхронность материал будет точно, но не скоро.
            0
            а я вот хочу узнать следующую вещь:
            объясните, зачем функции file и list присваиваются в конструкторе? почему они не устанавливаются через prototype?
            ведь, если я не ошибаюсь, в данном случае при создании двух и более инстансов App в каждом и них под эти функции будет выделен отдельный кусок памяти.
              +2
              Вы правы, при создании методов в конструкторе именно в каждом инстансе будут создаваться свои собственные методы, поэтому обычно методы делают через прототип.
              Но в данном случае, класс App предназначен для инкапсуляции логики приложения, и не подразумевается в одном приложении создавать несколько объектов этого класса. Поэтому, автор решил поместить создание методов в конструктор, для наглядности, чтобы не плодить сложности в App.js.
              Думаю, в реальном большом приложении все эти методы класса App будут разнесены в разные файлы, и подключаться в приложение из этих файлов.
              +3
              Вы только в начале пути. В последнем своём проекте, о котором я напишу после его окончания, я сделал следующее:
              1. Разделил собственно приложение (настройка express app) и функции, которые обрабатывают запросы (назвал их контроллёрами)
              2. Функции-контроллёры получают список входящих параметров с помощью инъекций по полной аналогии с angular framework.

              Т.е. просто применил MVC шаблон. Это реально освободило меня от шаблонного кода и я больше сосредоточен на логике функций-контроллёров.

              Если схематично, то получается следующее:
              // listCtrl.js
              module.exports = listCtrl = function(request, response, app) {
              };
              listCtrl.$inject = ['request', 'response', 'app'];
              
              // fileCtrl.js
              module.exports = fileCtrl = function(request, response, app) {
              };
              fileCtrl.$inject = ['request', 'response', 'app'];
              
              // app.js
              var routes = [
                {
                  path : '/',
                  method : 'get',
                  controller : require('./controllers/listCtrl.js')
                },
                {
                  path : '/file',
                  method : 'get',
                  controller : require('./controllers/fileCtrl.js')
                }
              ];
              
              // middleware которая набивает маршрутами и обработчиками express по модели, делает инъекции при вызове контроллёра и т.д.
              express.use(router(routes));
              
              


              Главным бонусом такого подхода является отличная тестируемость функций-контроллёров (из-за внедрения зависимостей). Когда закончу проект, посмотрю что получилось и выложу на суд общественности.
                0
                Для поддержки разных конфигов (для production, development, testing и т.д.) мы в проекте используем www.npmjs.org/package/config
                Довольно удобно.
                  0
                  Я думаю, что этот пакет заслуживает отдельного обзора.
                  0
                  Да, модель angular очень крутая и я с ней сейчас работаю, но, пока, на уровне экспериментов. В вашем же случае вы объединяете логику и интерфейс, а я пишу о том, что этого делать не нужно.
                    0
                    Не совсем понял где Вы увидели у меня смешение интерфейса и логики? Может в моём коде не хватает комментариев для лучшего понимания…
                    1. Код конроллёров полностью вынесен из http-интерфейса. Я отметил в коде, что каждый контроллёр находится в отдельном файле и список его входных параметров чётко обозначен. Это даёт им хорошую тестируемость и переиспользование.
                    2. Код http-интерфейса практически не растёт с ростом методов. Увеличивается только модель интерфейса (в коде это routes). Таким образом отпадает надобность его тестировать вообще.

                    В вашем варианте у Вас всё-таки остаётся код в интерфейсе.
                    server.get('/file', function(req, res, next){
                        app.file(req.query.file, function(err, file){
                            if (err) return next(err);
                    
                            res.type('text/plain').end(file);
                        });
                    });
                    

                    Вам придётся написать на него тест. Но в таком варианте Вы этого сделать не сможете. Это довольно сложно. А сложных тестов быть не должно.

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

                      Единственное могу посоветовать, все-таки, использовать именованные функции в контролерах:
                      module.exports = FileCtrl;
                      
                      FileCtrl.$inject=['request','response', 'app'];
                      function FileCtrl(req, res, app) {
                          //...
                      }
                      


                      По спецификации именованные функции объявляются сразу после парсинга кода.
                        0
                        В реальности так и делаю, здесь просто для краткости.
                        Хорошо, что отметили, пусть останется для читателей.
                  0
                  А у меня такой вопрос, почему для работы с MongoDB вы использутете MongoClient, а не Mongoose?
                    0
                    Не хотелось плодить сущности не относящиеся к основной идее. А вообще мне не нравятся схемы и валидация в mongoose, гораздо удобнее они реализованы в Waterline ORM.

                  Only users with full accounts can post comments. Log in, please.