Построение полноценного MVC веб-сайта на ExpressJS

в 13:42, , рубрики: expressjs, JS, mvc, node.js, tutorial, переводы

Построение полноценного MVC веб сайта на ExpressJSNB: Это материал для тех, кто уже ознакомился с теоретической основой node.js и хочет, как говорится, с места в карьер — поскорей окунуться в разработку с применением этого инструмента. Никакой дедукции, only coding. Если заинтересовало, не стесняемся, проходим под кат.


От переводчика: Я сам начал изучать node.js совсем недавно. Несомненно, в интернете есть много хороших(!) мануалов для новичков, в частности и здесь, на хабре (например тут и тут). Однако данный мануал показался мне особенно примечательным, так как в разумно краткой и понятной форме описывает процесс построения полноценного node.js-приложения на базе модуля Express с применением MVC-паттерна и базы данных MongoDB. Также автор уделяет внимание такой немаловажной вещи, как тестирование.

ПЕРЕВОД

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

Вступление

Express — один из лучших фреймворков для Node. Он имеет отличную поддержку со стороны разработчиков и кучу полезных функций. Есть много замечательных статей по Express, которые охватывают все основы работы с ним. Однако сейчас я хочу копнуть немного глубже и поделиться своим опытом в создании полноценного веб-сайта. В целом, эта статья не только о самом Express, но и о его комбинации с другими, не менее замечательными инструментами, которые доступны для разработчиков.
Я полагаю то, что Вы знакомы с Node.js, установили его на свою систему, и что Вы уже создавали на нём какие-то приложения.
В основе фреймворка Express лежит Connect. Это набор middleware-функций, которое поставляется вместе с множеством полезных вещей. Если Вам интересно, что же такое middleware-функция, вот Вам небольшой пример:

var connect = require('connect'),
    http = require('http');
 
var app = connect()
    .use(function(req, res, next) {
        console.log("That's my first middleware");
        next();
    })
    .use(function(req, res, next) {
        console.log("That's my second middleware");
        next();
    })
    .use(function(req, res, next) {
        console.log("end");
        res.end("hello world");
    });
http.createServer(app).listen(3000); 

Middleware-функцией в основном зовётся функция, которая принимает в качестве параметров request и responce-объекты, а также callback-функцию, которая будет вызвана следующей. Каждая middleware-функция решает: либо ответить, используя responce-объект, либо передать поток следующей callback-функции. В примере выше, если Вы уберёте вызов метода next() во второй функции, строка “hello world” никогда не будет отправлена клиенту. В целом, именно как работает Express. В нём есть несколько предопределённых middleware-функций, которые, несомненно, сэкономят Вам кучу времени. Таковой, например, является Body-парсер, разбирающий тело запроса и поддерживающий типы содержимых application/json, application/x-www-form-urlencoded и multipart/form-data. Или Cookie-парсер, который разбирает куки-заголовки и заполняет поле req.cookies объектом, ключом которого будет имя куки.
Фактически, Express обёртывает собой Connect-фреймворк, дополняя его некоторой функциональностью, такой, например, как логика маршрутизации, которая делает прохождение процесса маршрутизации более гладким. Ниже приведён пример обработки GET-запроса:

app.get('/hello.txt', function(req, res){
    var body = 'Hello World';
    res.setHeader('Content-Type', 'text/plain');
    res.setHeader('Content-Length', body.length);
    res.end(body);
});

Установка

Есть два варианта установки Express. Первая — это поместить её описание в файл package.json и выполнить команду npm install (ходит такая шутка, что название данного менеджера пакетов расшифровывается как «no problem man» :)).

{
    "name": "MyWebSite",
    "description": "My website",
    "version": "0.0.1",
    "dependencies": {
        "express": "3.x"
    }
}

Код фреймворка будет помещён в директорию node_modules и у Вас будет возможность создать его экземпляр. Я предпочитаю альтернативный вариант: командную строку. Просто установите Express, выполнив команду npm install -g express. Сделав это, Вы будете иметь новый CLI инструмент. Например, если Вы выполните:

express --sessions --css less --hogan app

Express сгенерирует для Вас сконфигурированный каркас приложения. Ниже приведён список параметров команды express(1):

Usage: express [options]
Options:
  -h, --help          output usage information
  -V, --version       output the version number
  -s, --sessions      add session support
  -e, --ejs           add ejs engine support (defaults to jade)
  -J, --jshtml        add jshtml engine support (defaults to jade)
  -H, --hogan         add hogan.js engine support
  -c, --css   add stylesheet  support (less|stylus) (defaults to plain css)
  -f, --force         force on non-empty directory

Как Вы видите, список доступных параметров небольшой, но мне этого вполне хватает. Как правило, я использую библиотеку less в качестве CSS-препроцессора, а также шаблонизатор hogan. В нашем примере нам необходима поддержка сессий, и параметр --sessions решит эту проблему. Когда выполнение команды завершится, структура нашего проекта будет выглядеть следующим образом:

/public
    /images
    /javascripts
    /stylesheets
/routes
    /index.js
    /user.js
/views
    /index.hjs
/app.js
/package.json

Если Вы загляните в файл package.json, Вы увидите, что все зависимости, которые нам нужны, сюда добавлены. Но они ещё не установлены. Для того, чтобы это сделать, просто выполните команду npm install, по завершении которой появится директория node_modules.
Я понимаю, что подход, описанный выше, не достоин называться универсальным. Например, Вам может потребоваться поместить обработчики маршрутов в иную директорию, или что-нибудь иное. Но, как будет показано в следующих нескольких главах, я буду вносить изменения в сгенерированную структуру, что осуществляется весьма просто. Исходя из этого, советую применять команду express(1) просто как шаблонный генератор.

FastDelivery

Для этой инструкции я создал простой веб-сайт выдуманной компании с названием FastDelivery. Вот скриншот готового дизайна:
image

В конце этого руководства мы будем иметь готовое веб-приложение с рабочей панелью управления. Идея заключается в создании возможности управления каждой частью сайта в отдельных для каждой части областях. Макет был создан в Photoshop и порезан на CSS(less) и HTML(hogan) файлы. Я не буду описывать тут процесс нарезки, так как это не предмет обсуждения в данной статье, но если у Вас возникли по этой части вопросы, не стесняйтесь спрашивать. Закончив с нарезкой, у нас получается следующая структура файлов приложения:

/public
    /images (there are several images exported from Photoshop)
    /javascripts
    /stylesheets
        /home.less
        /inner.less
        /style.css
        /style.less (imports home.less and inner.less)
/routes
    /index.js
/views
    /index.hjs (home page)
    /inner.hjs (template for every other page of the site)
/app.js
/package.json

Здесь приведён список элементов сайта, которые у нас будет возможность администрировать:

  • Домашняя страница (баннер в центре – заголовок и текст)
  • Блог (добавление, удаление и редактирование статей)
  • Страница услуг
  • Страница карьер (профессиональный рост, а не песочная яма :) )
  • Страница контактов

Конфигурация

Есть несколько вещей, которые нам надо сделать до того, как мы приступим к разработке. Настройка конфигурации — одна из таких вещей. Давайте представим, что наш маленький стайт будет развёрнут на трёх различных серверах — на локальном, на промежуточном и на боевом. Естественно, что настройки всех трёх окружений различны, и потому нам надо реализовать механизм, который будет достаточно гибкий для таких условий. Как Вы знаете, каждый nodejs-скрипт выполняется как консольное приложение. Это значит, что мы с лёгкостью можем приписать команде параметры, в которых будет определено текущее окружение. Я завернул эту часть в отдельный модуль, чтобы было удобно позже написать для него тесты. Файл /config/index.js:

var config = {
    local: {
        mode: 'local',
        port: 3000
    },
    staging: {
        mode: 'staging',
        port: 4000
    },
    production: {
        mode: 'production',
        port: 5000
    }
}
module.exports = function(mode) {
    return config[mode || process.argv[2] || 'local'] || config.local;
}

На данный момент мы имеем только два параметра — режим (mode) и порт (port). Как Вы могли предположить, приложение использует разные порты на разных серверах. Именно поэтому мы должны обновить точку входа нашего сайта, в файле app.js.

...
var config = require('./config')();
...
http.createServer(app).listen(config.port, function(){
    console.log('Express server listening on port ' + config.port);
});

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

node app.js staging

Выведет:

Express server listening on port 4000

Теперь все наши настройки хранятся в одном месте, ими просто управлять.

Тесты

Я большой фанат TDD. Я попробую покрыть тестами все базовые классы, которые будут упомянуты в этой статье. Согласен, написание тестов абсолютно для всего отнимет очень много времени, однако в общем, именно так Вы и должны создавать свои приложения. Один из моих любимых фреймворков для тестирования является jasmine. Конечно, и он доступен в пакетном менеджере npm:

npm install -g jasmine-node

Давайте создадим дирекотрию для хранения наших тестов. Первая вещь, которую мы будем тестировать, это наш конфигурационный файл. Имена тестовых файлов должны заканчиваться на .spec.js, так что файл мы назовём config.spec.js.

describe("Configuration setup", function() {
    it("should load local configurations", function(next) {
        var config = require('../config')();
        expect(config.mode).toBe('local');
        next();
    });
    it("should load staging configurations", function(next) {
        var config = require('../config')('staging');
        expect(config.mode).toBe('staging');
        next();
    });
    it("should load production configurations", function(next) {
        var config = require('../config')('production');
        expect(config.mode).toBe('production');
        next();
    });
});

Выполните команду jasmine-node ./tests и Вы увидете следующее:

Finished in 0.008 seconds
3 tests, 6 assertions, 0 failures, 0 skipped

Сейчас я написал сперва реализацию, а только потом тесты. Это не является TDD-подходом, но в следующих главах я буду делать наоборот.

Я настоятельно рекомендую проводить достаточно времени за написанием тестов. Нет ничего лучше, чем полностью покрытое тестами приложение.
Несколько лет назад я осознал кое-что очень важное, то, что может помочь Вам создавать более качественные приложения. Каждый раз, когда Вы начинаете писать новый класс, новый модуль, или просто часть логики, задайтесь вопросом:

Как я могу это протестировать?

Ответ на этот вопрос поможет Вам писать код более эффективно, создавать хорошие API и разделять всё на красиво разделённые блоки. Вы не можете написать тесты для спагетти-кода. Например, в конфигурационном файле, упомянутом выше (/config/index.js), я добавил возможность отправки режима (mode) в конструктор модуля. Вы вполне можете удивиться: зачем я так сделал, ведь основной идеей является получение значения для режима из аргументов командной строки? Всё просто: потому что мне нужно протестировать его. Давайте представим, что месяцем спустя мне понадобится протестировать что-то, используя конфигурацию для боевого сервера, а нодовый скрипт выполняется с подставляющимся параметром. И у меня не будет возможности внести изменение без этого маленького усовершенствования. Этот предыдущий маленький шаг предотвратит возможные проблемы с развёртыванием приложения в будущем.

База данных

Если мы конструируем динамический веб-сайт, нам необходима база данных для хранения информации в ней. В качестве оной я выбрал mongodb для этой инструкции. Mongo — это NoSQL-база данных. Инструкцию по установке Вы найдёте тут, и, так как я пользователь Windows, я следовал инструкции по установке для Windows. После того, как Вы закончите с установкой, запустите демон MongoDB, который по умолчанию слушает порт 27017. Итак, в теории, у нас имеется возможность соединиться с этим портом и обмениваться сообщениями с сервером mongodb. Чтобы сделать это через node-скрипт, нам нужен mongodb-модуль/драйвер. Если Вы скачали исходные файлы для этой инструкции, значит модуль уже добавлен в файл package.json. Если же нет, просто добавьте «mongodb»: «1.3.10» в свойство dependencies и выполните команду npm install. Далее, мы напишем тест, который будет проверять, запущен ли mongodb-сервер.

Файл /tests/mongodb.spec.js:

describe("MongoDB", function() {
    it("is there a server running", function(next) {
        var MongoClient = require('mongodb').MongoClient;
        MongoClient.connect('mongodb://127.0.0.1:27017/fastdelivery', function(err, db) {
            expect(err).toBe(null);
            next();
        });
    });
});

Callback-функция в методе mongodb-клиента .connect получает объект db. Позже мы задействуем его для управдения нашими данными. Это означает, что нам нужен доступ к нему внутри наших моделей. Создавать каждый раз при запросе в базу данных новый объект MongoClient будет не очень хорошей идеей. Именно поэтому я перенёс запуск Express-сервера в callback функции соедиения с БД:

MongoClient.connect('mongodb://127.0.0.1:27017/fastdelivery', function(err, db) {
    if(err) {
        console.log('Sorry, there is no mongo db server running.');
    } else {
        var attachDB = function(req, res, next) {
            req.db = db;
            next();
        };
        http.createServer(app).listen(config.port, function(){
            console.log('Express server listening on port ' + config.port);
        });
    }
});

Так как у нас есть файл настроек конфигурации окружений, будет хорошей идеей поместить сюда имя хоста и порт mongodb-сервера и поменять URL соединения на:

'mongodb://' + config.mongo.host + ':' + config.mongo.port + '/fastdelivery'

Обратите особое внимание на middleware-функцию attachDB, которую я добавил прямо перед вызовом функции http.createServer. Благодаря этому небольшому дополнению мы будем заполнять свойство .db request-объекта. Хорошая новость заключается в том, что мы сможем задать несколько функций во время определения маршрута. Например:

app.get('/', attachDB, function(req, res, next) {
    ...
})

Таким образом Express вызывает функцию attachDB заранее, чтобы она попала в наш обработчик маршрута. Когда это произойдёт, request-объект будет иметь свойство .db и мы сможем использовать его для доступа к БД.

MVC

Всем нам знаком шаблон MVC. Вопрос в том, как применить его в Express. Так или иначе, это вопрос интерпретации. В следующих нескольких главах я создам модули, которые будут работать как модель, представление и контроллер.

Модель

Модель — это то, что будет обрабатывать данные нашего приложения. У неё должен быть доступ к объекту db, возвращаемый объектом MongoClient. Наша модель также должна иметь метод для её расширения, так как нам могут потребоваться различные типы моделей. Например, мы можем захотеть создать модель BlogModel или ContactsModel. Для этого мы для начала должны создать тест: /tests/base.model.spec.js. И помните: создавая эти тесты, мы сможем гарантировать то, что наши модели будут делать только то, что мы хотим, и ни что иное.

var Model = require("../models/Base"),
    dbMockup = {};
describe("Models", function() {
    it("should create a new model", function(next) {
        var model = new Model(dbMockup);
        expect(model.db).toBeDefined();
        expect(model.extend).toBeDefined();
        next();
    });
    it("should be extendable", function(next) {
        var model = new Model(dbMockup);
        var OtherTypeOfModel = model.extend({
            myCustomModelMethod: function() { }
        });
        var model2 = new OtherTypeOfModel(dbMockup);
        expect(model2.db).toBeDefined();
        expect(model2.myCustomModelMethod).toBeDefined();
        next();
    })
});

Вместо настоящего объекта db я решил передавать макет объекта. Это делается для случая, если позже я захочу протестировать что-то конкретное, что будет зависеть от данных, поступающих из базы данных. Будет намного проще определять эти данные вручную.
Реализация расширения метода — немного непростая задача, так как мы должны изменить прототип module.exports, но оставить нетронутым первоначальный конструктор. К счастью, у нас есть уже написанный тест, который проверяет работоспособность нашего кода. Версия приведённого выше фрагмента кода выглядит так:

module.exports = function(db) {
    this.db = db;
};
module.exports.prototype = {
    extend: function(properties) {
        var Child = module.exports;
        Child.prototype = module.exports.prototype;
        for(var key in properties) {
            Child.prototype[key] = properties[key];
        }
        return Child;
    },
    setDB: function(db) {
        this.db = db;
    },
    collection: function() {
        if(this._collection) return this._collection;
        return this._collection = this.db.collection('fastdelivery-content');
    }
}

Здесь определены два вспомогательных метода. Сеттер для объекта db и геттер для коллекции из нашей БД.

Представление

Представление будет выводить информацию на экран. Говоря простым языком, представление — это класс, который отправляет браузеру ответ. Express предоставляет простую возможность это делать:

res.render('index', { title: 'Express' });

Response-объект — это обёртка, которая имеет хороший API, делающий наши жизни проще. Однако я предпочту создать свой модуль, в который будет закатана его функциональность. Стандартная директория представлений будет заменена на templates, а старая директория views будет раздавать нам класс представления Base.
Это маленькое изменение теперь требует другого изменения. Мы должны уведомить Express о том, что наши файлы шаблонов теперь находятся в другой директории:

app.set('views', __dirname + '/templates');

Во-первых, я определю всё, что мне нужно, напишу тесты, и после этого начну имплементацию. Нам нужен модуль, удовлетворяющий следующим требованиям:

  • Его конструктор должен получать response-объект и имя шаблона
  • Он должен иметь метод вывода (рендеринга) шаблонов, который принимает объект data
  • Он должен быть расширяемый

Возможно, Вы удивитесь: зачем это мне понадобилось расширять класс представления? Разве он не просто вызывает метод response.render? Чтож, на практике бывают случаи, когда Вам надо послать иной заголовок, или же поманипулировать response-объектом. Например, обработать данные, пришедшие в JSON:

var data = {"developer": "Krasimir Tsonev"};
response.contentType('application/json');
response.send(JSON.stringify(data));

Вместо того, чтобы делать это каждый раз, было бы здорово иметь классы HTMLView и JSONView. Или же класс XMLView, для отправки браузеру данных в формате XML. Просто будет лучше, если Вы создадите большое веб-приложение, в котором уже будет реализован такой функционал, чем если будете копипастить один и тот же код раз за разом.
Ниже приведён тест для класса /views/Base.js:

var View = require("../views/Base");
describe("Base view", function() {
    it("create and render new view", function(next) {
        var responseMockup = {
            render: function(template, data) {
                expect(data.myProperty).toBe('value');
                expect(template).toBe('template-file');
                next();
            }
        }
        var v = new View(responseMockup, 'template-file');
        v.render({myProperty: 'value'});
    });
    it("should be extendable", function(next) {
        var v = new View();
        var OtherView = v.extend({
            render: function(data) {
                expect(data.prop).toBe('yes');
                next();
            }
        });
        var otherViewInstance = new OtherView();
        expect(otherViewInstance.render).toBeDefined();
        otherViewInstance.render({prop: 'yes'});
    });
});

Для того, чтобы протестировать рендеринг (визуализацию), я должен был создать макет. Для такого случая в первой части теста я создал объект, который имитирует response-объект фреймворка Express. Во второй части теста я создал другой класс View, который наследует базовый класс и применяет собственный метод рендеринга.
Код класса /views/Base.js:

module.exports = function(response, template) {
    this.response = response;
    this.template = template;
};
module.exports.prototype = {
    extend: function(properties) {
        var Child = module.exports;
        Child.prototype = module.exports.prototype;
        for(var key in properties) {
            Child.prototype[key] = properties[key];
        }
        return Child;
    },
    render: function(data) {
        if(this.response && this.template) {
            this.response.render(this.template, data);
        }
    }
}

Теперь у нас есть три теста в нашей директории для тестов, и если Вы запустите команду jasmine-node ./tests, результат должен быть следующим:

Finished in 0.009 seconds
7 tests, 18 assertions, 0 failures, 0 skipped

Контроллер

Помните, мы говорили про маршруты, и как они определялись?

app.get('/', routes.index);

Символ '/' после маршрута в примере выше и есть контроллер. Это та самая middleware-функция, которая принимает в себя объекты request, response и callback-функцию next().

exports.index = function(req, res, next) {
    res.render('index', { title: 'Express' });
};

Выше описано, как должен выглядеть ваш контроллер, в контексте применения Express. Команда express(1) создаёт директорию под имененм routes, но в нашем случае лучше будет, если она будет называться controllers. Я переименовал её, чтобы файловая структура соответствовала этой схеме.
Так как мы строим не просто маленькое крошечное приложение, было бы целесообразно создать базовый класс, который можно расширять. Если нам когда-нибудь понадобится добавить функционала всем нашим контроллерам, этот базовый класс был бы идеальным местом для реализации дополнений. Я снова сначала пишу тест, так что давайте определим, что нам нужно для базового класса:

  • Он должен иметь расширяемый метод, который принимает объект и возвращает новый дочерний экземпляр
  • Дочерний экземпляр должен иметь метод run, которым является старая добрая middleware-функция
  • Должно быть свойство name, которое идентифицирует контроллер
  • У нас должна быть возможность создавать независимые объекты, наследуемые от базового класса

Всего лишь неколько пунктов, но мы можем добавить ещё функциональности позже.
Тест будет выглядеть примерно так:

var BaseController = require("../controllers/Base");
describe("Base controller", function() {
    it("should have a method extend which returns a child instance", function(next) {
        expect(BaseController.extend).toBeDefined();
        var child = BaseController.extend({ name: "my child controller" });
        expect(child.run).toBeDefined();
        expect(child.name).toBe("my child controller");
        next();
    });
    it("should be able to create different childs", function(next) {
        var childA = BaseController.extend({ name: "child A", customProperty: 'value' });
        var childB = BaseController.extend({ name: "child B" });
        expect(childA.name).not.toBe(childB.name);
        expect(childB.customProperty).not.toBeDefined();
        next();
    });
});

А ниже приведена реализация /controllers/Base.js:

var _ = require("underscore");
module.exports = {
    name: "base",
    extend: function(child) {
        return _.extend({}, this, child);
    },
    run: function(req, res, next) {
 
    }
}

Разумеется, каждый класс-потомок должен иметь свой метод run, реализующий свою логику.

Веб-сайт FastDelivery

Чтож, у нас имеется хороший набор классов для нашей MVC архитектуры, также мы покрыли тестами все модули. Теперь мы готовы продолжить заниматься сайтом для нашей вымышленной компании FastDelivery. Давайте представим, что сайт будет состоять из двух частей — пользовательской части и администрационной панели. Пользовательская часть будет предназначаться для презентации информации, хранящейся в БД, конечному пользователю. Администрационная панель будет для управления этой информацией. Начнём с последней.

Панель управления

Давайте сперва создадим простой контроллер, который будет обслуживать страничку админ-панели.
Файл /controllers/Admin.js:

var BaseController = require("./Base"),
    View = require("../views/Base");
module.exports = BaseController.extend({
    name: "Admin",
    run: function(req, res, next) {
        var v = new View(res, 'admin');
        v.render({
            title: 'Administration',
            content: 'Welcome to the control panel'
        });
    }
});

Используя написанные заранее базовые классы наших контроллеров и представлений, мы запросто можем создать входную точку для администрационной панели. Класс View принимает имя файла шаблона. Согласно коду выше, файл должен быть назван admin.hjs и должен быть помещён в директорию /templates. Его содержание будет выглядеть примерно так:

<!DOCTYPE html>
<html>
    <head>
        <title>{{ title }}</title>
        <link rel='stylesheet' href='/stylesheets/style.css' />
    </head>
    <body>
        <div class="container">
            <h1>{{ content }}</h1>
        </div>
    </body>
</html>

(Чтобы данная инструкция оставалась достаточно короткой и в простом для чтения формате, я не буду приводить тут код каждого шаблона. Я настоятельно рекомендую, чтобы Вы скачали исходный код с GitHub.)
Теперь, чтобы сделать наш контроллер видимым, нам надо добавить маршрут в app.js:

var Admin = require('./controllers/Admin');
...
var attachDB = function(req, res, next) {
    req.db = db;
    next();
};
...
app.all('/admin*', attachDB, function(req, res, next) {
    Admin.run(req, res, next);
});

Учитывайте, что мы не посылаем метод Admin.run как middleware-функцию напрямую. Это потому, что мы хотим сохранить контекст. Если мы сделаем так:

app.all('/admin*', Admin.run);

слово this в объекте Admin будет указывать на совершенно другое место.

Защита админ-панели

Каждая станица, которая начинается с /admin, должна быть защищена. Чтобы достичь этого, мы будем использовать встроенную в Express middleware-функцию Sessions. Она просто добавляет объект к запросу, который называется session. Теперь мы должны изменить наш контроллер Admin так, чтобы он выполнял две следующие вещи::

  • Он должен проверять, доступна ли сессия. Если нет, выводить форму авторизации
  • Он должен принимать данные, отправленные из формы авторизации и авторизирвать пользователя, если логин и пароль совпали с логином и паролем из базы

Ниже приведена небольшая вспомогательная функция, которая будет удовлетворять описанным выше требованиям:

authorize: function(req) {
    return (
        req.session &&
        req.session.fastdelivery &&
        req.session.fastdelivery === true
    ) || (
        req.body &&
        req.body.username === this.username &&
        req.body.password === this.password
    );
}

Сперва выполняется скрипт, который пытается узнать пользователя через объект session. Затем мы проверяем, были ли присланы нам данные из формы. Если да, данные из формы доступны нам из объекта request.body, который подготовила для нас middleware-функция bodyParser. Затем мы просто проверяем, совпадают ли логин и пароль.
А теперь настаёт время для метода run из контроллера, который использует наш новоиспечённый хелпер (базовую обёртку для всех контроллеров). Мы проверяем: если пользователь авторизирован, показываем ему панель управления, в противном случае выводим страницу авторизации:

run: function(req, res, next) {
    if(this.authorize(req)) {
        req.session.fastdelivery = true;
        req.session.save(function(err) {
            var v = new View(res, 'admin');
            v.render({
                title: 'Administration',
                content: 'Welcome to the control panel'
            });
        });         
    } else {
        var v = new View(res, 'admin-login');
        v.render({
            title: 'Please login'
        });
    }       
}
Управление контентом

Как я и сказал в начале этой статьи, у нас имеется немало вещей, которые мы будем администрировать. Чтобы упростить процесс, давайте будем держать все данные в одной коллекции. Каждая запись будет иметь следующие свойства: заголовок, текст, картинку и тип. Последнее будет определять владельца записи. Для примера, странице контактов необходима только одна запись с типом “contacts”, в то время как страница блога требует больше записей. Итак, нам нужно добавить три новых страницы, для добавления, удаления и редактирования записей. Перед тем, как мы начнём создавать новые шаблоны, стилизовать и добавлять новые штуки в контроллер, мы должны написать класс для нашей модели, которая будет стоять между MongoDB-сервером и нашим приложением, и, конечно, предоставлять нам удобный API.

// /models/ContentModel.js 
var Model = require("./Base"),
    crypto = require("crypto"),
    model = new Model();
var ContentModel = model.extend({
    insert: function(data, callback) {
        data.ID = crypto.randomBytes(20).toString('hex');
        this.collection().insert(data, {}, callback || function(){ });
    },
    update: function(data, callback) {
        this.collection().update({ID: data.ID}, data, {}, callback || function(){ });  
    },
    getlist: function(callback, query) {
        this.collection().find(query || {}).toArray(callback);
    },
    remove: function(ID, callback) {
        this.collection().findAndModify({ID: ID}, [], {}, {remove: true}, callback);
    }
});
module.exports = ContentModel;

Модель берёт на себя заботу о генерации уникального ID для каждой записи. Нам он понадобится, чтобы позже обновлять информацию.
Если нам надо добавить новую запись на страницу контактов, мы можем написать следующее:

var model = new (require("../models/ContentModel"));
model.insert({
    title: "Contacts",
    text: "...",
    type: "contacts"
});

Итак, у нас имееется неплохой API для управления данными в нашей коллекции mongodb. И теперь мы готовы написать UI, чтобы использовать весь этот функционал. На этот раз контроллер Admin будет изменён совсем немного. Чтобы упростоить нам задачу, я решил совместить список добавленных записей и форму добавления/редактирования записи. Как вы можете видеть на скриншоте ниже, левая часть страницы зарезервирована под список, а правая — под форму.
image
Отображение всех элементов управления на одной странице означает, что мы должны сосредоточиться на той части, которая рендерит эту страницу, или, если быть более конкретным, на данных, которые мы отправляем в шаблон. Поэтому я создал несколько объединённых вспомогательных функций, таких как эти:

var self = this;
...
var v = new View(res, 'admin');
self.del(req, function() {
    self.form(req, res, function(formMarkup) {
        self.list(function(listMarkup) {
            v.render({
                title: 'Administration',
                content: 'Welcome to the control panel',
                list: listMarkup,
                form: formMarkup
            });
        });
    });
});

Да, выглядит немного безобразно, но зато работает именно так, как я хочу. Первый хелпер — метод del, который проверяет текущие GET-параметры, и, если находит комбинацию action=delete&id=[id of the record], то удаляет соответствующие данные из коллекции. Вторая функция называется form, она ответственна главным образом за отображение формы в правой части страницы. Функция проверяет, пришла ли форма на сервер и должным образом обновляет или создаёт записи в БД. В конце метод list извлекает информацию и подготавливает HTML-таблицу, которая позже будет отправлена в шаблон. Реализация этих трёх хелперов может быть найдена в исходном коде к этой инструкции.
Ниже показана функция, которая обрабатывает загрузку файла:

handleFileUpload: function(req) {
    if(!req.files || !req.files.picture || !req.files.picture.name) {
        return req.body.currentPicture || '';
    }
    var data = fs.readFileSync(req.files.picture.path);
    var fileName = req.files.picture.name;
    var uid = crypto.randomBytes(10).toString('hex');
    var dir = __dirname + "/../public/uploads/" + uid;
    fs.mkdirSync(dir, '0777');
    fs.writeFileSync(dir + "/" + fileName, data);
    return '/uploads/' + uid + "/" + fileName;
}

Если файл отправлен на сервер, свойство .files request-объекта будет заполнено соответствующими данными. В нашем случае мы имеем следующий HTML-элемент:

<input type="file" name="picture" />

Это означает, что мы можем получить доступ к отправленным данным через req.files.picture. Как показано в примере выше, req.files.picture.path используется для получения сырого (необработанного) содержимого файла. Далее, присланный файл сохраняется в созданную для загрузок директорию, и в самом конце, возвращается соответствующий URL до этого файла. Все эти операции синхронны, но хорошей практикой всё же является применять асинхронную версию методов readFileSync, mkdirSync и writeFileSync.

Пользовательская часть

Самая сложная часть теперь готова. Админ-панель работает, и у нас есть класс ContentModel, который даёт нам доступ к информации, хранящейся в БД. Что нам теперь нужно сделать, это написать контроллеры для пользовательской части и привязать их к сохранённому контенту. Вот контроллер для домашней страницы – /controllers/Home.js

module.exports = BaseController.extend({
    name: "Home",
    content: null,
    run: function(req, res, next) {
        model.setDB(req.db);
        var self = this;
        this.getContent(function() {
            var v = new View(res, 'home');
            v.render(self.content);
        })
    },
    getContent: function(callback) {
        var self = this;
        this.content = {};
        model.getlist(function(err, records) {
            ... storing data to content object
            model.getlist(function(err, records) {
                ... storing data to content object
                callback();
            }, { type: 'blog' });
        }, { type: 'home' });
    }
});

Домашняя страница требует одну запись с типом “home” и четыре записи с типом “blog”.
Как только контроллер будет готов, нам нужно будет добавить маршрут до него в наш app.js:

app.all('/', attachDB, function(req, res, next) {
    Home.run(req, res, next);
});

Мы снова прикрепляем объект db к request-объекту. По большей части тот же процесс, который мы выполняли при создании админ-панели.
Другие страницы для пользовательской части практически идентичны в том, что они все имеют контроллер, который получает данные при помощи класса модели, и, конечно, имеет маршрут. Есть два интересных аспекта, которые я бы хотел осветить более подробно. Первый связан со страницей блога. Страница должна показывать как все статьи, так и отдельно взятую. Определим пару маршрутов:

app.all('/blog/:id', attachDB, function(req, res, next) {
    Blog.runArticle(req, res, next);
});
app.all('/blog', attachDB, function(req, res, next) {
    Blog.run(req, res, next);
});

Они оба используют один и тот же контроллер: Blog, но вызывают два разных метода run. Обратите внимание на строку /blog/:id. Данный маршрут будет совпадать с такими URL’ами, как: /blog/4e3455635b4a6f6dccfaa1e50ee71f1cde75222b, где этот длинный хэш будет доступен через свойство req.params.id. Другими словами, мы можем определять динамические параметры. В нашем случае это будет ID записи (статьи). Теперь, когда мы узнали об этом, мы можем генерировать уникальную страницу для каждой статьи.
Вторым интересным аспектом является то, как я буду создавать страницы услуг, карьер и контактов. Понятно, что все они будут использовать только одну запись из БД. Если бы нам надо было создавать контроллер для каждой страницы, мы бы просто копипастили один и тот же код и лишь меняли в нём поле type. Есть лучший способ сделать это, через один контроллер, который будет принимать тип записи в свой метод run. Вот маршруты:

app.all('/services', attachDB, function(req, res, next) {
    Page.run('services', req, res, next);
});
app.all('/careers', attachDB, function(req, res, next) {
    Page.run('careers', req, res, next);
});
app.all('/contacts', attachDB, function(req, res, next) {
    Page.run('contacts', req, res, next);
});

А вот как будет выглядеть контроллер:

module.exports = BaseController.extend({
    name: "Page",
    content: null,
    run: function(type, req, res, next) {
        model.setDB(req.db);
        var self = this;
        this.getContent(type, function() {
            var v = new View(res, 'inner');
            v.render(self.content);
        });
    },
    getContent: function(type, callback) {
        var self = this;
        this.content = {}
        model.getlist(function(err, records) {
            if(records.length > 0) {
                self.content = records[0];
            }
            callback();
        }, { type: type });
    }
});

Развёртывание

Развёртывание веб-сайта на базе Express, по сути, ничем не отличается от развёртывания любого другого приложения на Node.js:

  • Файлы заливаются на сервер
  • Node-процесс должен быть остановлен (если он запущен)
  • Для установки всех необходимых зависимостей выполняется команда npm install
  • Главный node-скрипт снова запускается

Имейте ввиду, что Node — очень молодой продукт, и не всё может работать так, как Вы ожидаете, однако улучшения в нём делаются всё время. Например, модуль forever гарантирует Вам беспрерывную работу Node.js. Вы можете запустить его, выполнив следующую команду:

forever start yourapp.js

Это то, что я использую на своих серверах. Этот маленький инструмент решает одну большую проблему. Если Вы запустите ваше приложение командой node yourapp.js, как только скрипт завершит свою работу неудачно, сервер упадёт, в то время как forever просто перезапускает приложение.
Я не системный администратор, но я хочу поделиться своим опытом интегрирования node-приложений в Apache и Nginx, так как считаю, что интеграция является частью процесса разработки. Как Вы знаете, Apache обычно бегает под портом 80, что значит, что если вы перейдёте в браузере по адресу localhost или localhost:80, Вы увидите страницу, обслуживаемую Apache-сервером, и, скорее всего, ваш node-скрипт слушает иной порт. Посему Вам нужно добавить виртуальный хост, который будет принимать запросы и отправлять их на нужный порт. Например, я хочу хостить сайт, который я только что создал, на моём локальном Apache-сервере, под адресом expresscompletewebsite.dev. Первая вещь, которую мне нужно сделать, это добавить свой домен в файл hosts.

127.0.0.1   expresscompletewebsite.dev

После этого я должен изменить файл httpd-vhosts.conf, находящийся в директории конфигурации Apache-сервера:

# expresscompletewebsite.dev
<VirtualHost *:80>
    ServerName expresscompletewebsite.dev
    ServerAlias www.expresscompletewebsite.dev
    ProxyRequests off
    <Proxy *>
        Order deny,allow
        Allow from all
    </Proxy>
    <Location />
        ProxyPass http://localhost:3000/
        ProxyPassReverse http://localhost:3000/
    </Location>
</VirtualHost>

Сервер всё ещё принимает запросы с порта 80, но пересылает их на порт 3000, который как раз и слушает node-скрипт.
Настройка Nginx намного проще, и, честно говоря, это лучший выбор для хостинга приложений на базе Node.js. Так же, как и в настройке Apache, вы должны добавить домен в файл hosts. После этого создайте новый файл в директории /sites-enabled, которая находится там, куда установлен Nginx. Содержимое файла должно выглядеть примерно так:

server {
    listen 80;
    server_name expresscompletewebsite.dev
    location / {
            proxy_pass http://127.0.0.1:3000;
            proxy_set_header Host $http_host;
    }
}

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

Заключение

Express — это отличный фреймворк, который даёт Вам хорошую основу для построения веб-приложений. Как Вы и сами можете видеть, это лишь вопрос выбора, как Вы будете расширять его и что Вы будете использовать вместе с ним. Он упрощает рутинные задачи, используя несколько замечательных middleware’ов и оставляет разработчикам процесс реализации самого вкусного.

Исходный код

Исходный код веб-сайта, который мы создали в этой статье, на GitHub – https://github.com/tutsplus/build-complete-website-expressjs. Не стесняйтесь форкать и модернизировать. Вот пошаговая инструкция для запуска веб-сайта:
Скачайте исходный код
Перейдите в директорию скачанного приложения
Выполние npm install
Запустите демон mongodb
Выполните node app.js

Автор: xphoenyx

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js