Создание полноценного MVC-сайта с помощью ExpressJS

СКАЧАТЬ ИСХОДНЫЕ КОДЫ

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

Введение

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

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

Предполагается, что вы знакомы с Node.js, имеете установленную копию на своем компьютере и определенный опыт разработки.

Сердцем Express является Connect. Это связующий фреймворк, содержащий много полезных вещей. Если вы не понимаете что из себя представляет связующий фреймворк (middleware framework), то взгляните на небольшой пример:

var connect = require('connect'),
    http = require('http');

var app = connect()
    .use(function(req, res, next) {
        console.log("Это мой первый Middleware Framework");
        next();
    })
    .use(function(req, res, next) {
        console.log("Это мой второй Middleware Framework");
        next();
    })
    .use(function(req, res, next) {
        console.log("Конец");
        res.end("Hello World!");
    });
 
http.createServer(app).listen(3000);

Middleware (промежуточное ПО) это функция, которая принимает запросы от объектов и отвечает на них. Каждое middleware может ответить, используя соответствующий объект или передать управление следующему middleware, используя функцию next().

В примере выше, при удалении вызова метода next() во втором middleware, строка «Hello World!» никогда не будет передана браузеру. Так, в общих чертах, работает Express.

В составе фреймворка имеется несколько предопределенных middleware, что, несомненно, экономит время. Например, парсер Body, поддерживающий типы application/json, application/x-www-form-urlencoded и multipart/form-data, который обрабатывает тело запроса. Или парсер Cookie, обрабатывающий заголовки cookie и populatesreq.cookies с помощью объекта, ассоциированного с именем cookie.

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.

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

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

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

После чего Express создаст скелет предварительно сконфигурированного приложения. Вот список управляющих команд для команды express:

Пример использования: express [список параметров]

Параметры:

-h, —help вывод справки по параметрам;
-V, —version вывод номера версии;
-s, —sessions активация поддержки сессий;
-e, —ejs активация поддержки движка ejs (по умолчанию для Jade);
-J, —jshtml активация поддержки движка jshtml (по умолчанию для Jade);
-H, —hogan активация поддержки движка hogan.js;
-c, —css активация поддержки стилей (Less|Stylus) (по умолчанию для Plain CSS);
-f, —force принудительные непустые директории.

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

В нашем примере, нам также понадобится поддержка сессий, в чем поможет аргумент —sessions. Когда команда, приведенная в листинге выше, выполнится, структура папок нашего проекта будет такой:

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

Если вы откроете файл package.json, то увидите, что все необходимые зависимости были добавлены, хотя они еще не были установлены. Чтобы сделать это, просто запустите установку через npm, и появится папка node_modules.

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

FastDelivery

Для этой статьи, я разработал простой сайт вымышленной компании FastDelivery. Вот скриншот, демонстрирующий законченный дизайн:

FastDelivery

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

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

/public
    /images (несколько изображений, экспортированных из Photoshop)
    /javascripts
    /stylesheets
        /home.less
        /inner.less
        /style.css
        /style.less (импорт home.less и inner.less)
/routes
    /index.js
/views
    /index.hjs (домашняя страница)
    /inner.hjs (шаблон всех страниц сайта)
/app.js
/package.json

Мы собираемся администрировать следующие элементы сайта:

  • Главная (баннер в центре – заголовок и текст);
  • Блог (добавление, удаление и редактирование статей);
  • Услуги;
  • Карьера;
  • Контакты.

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

До написания кода нам нужно сделать несколько вещей. Представим, что наш маленький сайт должен быть развернут в трех разных местах – на локальном, тестовом и производственном серверах. Конечно, для каждого из них настройки будут различаться и нужно реализовать достаточно гибкий механизм.

Как известно, каждый скрипт node.js запускается в виде консольной программы. Поэтому мы легко можем указать аргументы, которые будут определять текущую среду. Я оформил этот код в виде отдельного модуля, чтобы позже протестировать. Вот содержание файла /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

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

Тестирование

Я большой приверженец подхода test-driven development (разработка через тестирование). Я попытаюсь рассказать обо всех основных классах, используемых в данной статье. Разумеется, тестирование абсолютно всего сделает статью неимоверно большой, и по этой причине я этого делать не буду.

Но в целом, у вас должно сложиться понимание, как это делается при создании собственных приложений. Одним из моих самых любимых фреймворков для тестирования является jasmine. Он также доступен для установки через npm:

npm install -g jasmine-node

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

describe("Конфигурация для", function() {
    it("локального сервера", function(next) {
        var config = require('../config')();
        expect(config.mode).toBe('local');
        next();
    });
    it("тестового сервера", function(next) {
        var config = require('../config')('staging');
        expect(config.mode).toBe('staging');
        next();
    });
    it("производственного сервера", 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

Итак, я сначала создал конфигурацию, а потом провел её тест. Это не слишком близко к подходу «разработка через тестирование», но далее, я буду более строго его придерживаться.

Очень рекомендуется уделить достаточное время тестированию. Нет ничего лучше, чем хорошо протестированное приложение.

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

Ответ на этот вопрос поможет вам писать код более эффективно, создавать более качественные API и грамотно располагать части программы по отдельным блокам. Вы не сможете написать тест для кода, запутанного как спагетти. Например, в конфигурационном файле выше (/config/index.js), я добавил возможность передавать режим в конструктор модуля.

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

Представим, что месяцем позже мне нужно будет проверить что-нибудь в конфигурации для производственного сервера, но скрипт node запускается с параметрами командной строки. Я вряд ли смогу вспомнить все настройки. Поэтому, отход от первоначальной идеи поможет в будущем избежать проблем.

База данных

После того, как мы создали динамический сайт, необходимо сохранить данные в базе. Для примера в данной статье, я буду использовать базу данных mongodb. Mongo это документо-ориентированная СУБД без поддержки SQL. Инструкции по её установке можно найти здесь.

Так как я использую Windows, то мне понадобятся инструкции по установке для Windows. После окончания установки, запустите демон MongoDB, который по-умолчанию слушает порт 27017. Теоретически, мы можем подключиться к этому порту и взаимодействовать с сервером mongodb.

Чтобы сделать это из node-скрипта, нам понадобится модуль/драйвер mongodb. Если вы скачаете исходные файлы к этой статье, то этот модуль в них уже включен в файле package.json. В противном случае, просто добавьте «mongodb»: «1.3.10» в список зависимостей и запустите установку через npm.

Далее, мы напишем тест, проверяющий запущен ли сервер mongodb, который будет располагаться в файле ./tests/mongodb.spec.js:

describe("MongoDB", function() {
    it("сервер запущен", 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-вызов в методе .connect клиента mongodb, посылает объект db. Мы будем использовать его позже для управления нашими данными. Это означает, что мы должны получать доступ к этим данным внутри нашей модели.

Создавать новый объект MongoClient каждый раз, когда нам нужно сделать запрос к базе данных это не самая лучшая идея. Вот почему я переместил запуск сервера Express в callback-вызов внутрь функции connect:

MongoClient.connect('mongodb://127.0.0.1:27017/fastdelivery', function(err, db) {
    if(err) {
        console.log('Извините, но сервер mongo db не запущен.');
    } else {
        var attachDB = function(req, res, next) {
            req.db = db;
            next();
        };
        http.createServer(app).listen(config.port, function(){
            console.log('Сервер Express слушает порт' + config.port);
        });
    }
});

Даже лучше, что вместо параметров командной строки мы использовали конфигурационный файл. Мы поместили туда имя хоста и номер порта mongodb, а затем изменили URL-адрес в функции connect на:

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

Обратите особое внимание на middleware под названием attachDB, которое я добавил сразу после вызова функции http.createServer.

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

Например:

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

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

MVC

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

Модель (Model)

Модель управляет данными в нашем приложении. Она должна иметь доступ к объекту db, который возвращается MongoClient. Наша модель также должна иметь метод для расширения этого объекта, потому что, возможно, мы захотим создать различные типы моделей.

Например, мы можем создать модель BlogModel или ContactsModel. Поэтому нужно создать новый spec-файл: /tests/base.model.spec.js, для тестирования двух этих будущих моделей. Помните, что определяя этот функционал ДО начала реализации в виде кода, мы гарантируем, что наш модуль будет делать только то, что от него ожидается.

var Model = require("../models/Base"),
    dbMockup = {};
describe("Модели", function() {
    it("должны создавать новые модели", function(next) {
        var model = new Model(dbMockup);
        expect(model.db).toBeDefined();
        expect(model.extend).toBeDefined();
        next();
    });
    it("быть расширяемыми", 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, я решил передать объект mockup. Это сделано потому, что позже, я, возможно, захочу сделать специфический тест, в зависимости от информации, поступающей из базы данных. Будет проще определить эти данные вручную.

Реализация расширенного метода немного более хитрая, потому что нам нужно изменить прототип 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');
    }
}

Вот два наших helper-метода: первый инициализирует объект db, а второй получает collection из базы данных.

Вид (View)

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

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

Объект response является оберткой (wrapper), которая имеет хороший API, делающий нашу жизнь проще. Однако я предпочитаю создать модуль, который будет инкапсулировать данный функционал. Сменим стандартную папку для видов на templates и создадим новый вид, который будет базовым классом (base view class).

Это маленькое изменение влечет за собой еще одно — нам нужно уведомить Express о том, что файлы нашего шаблона теперь размещены в другой папке:

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

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

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

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

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

Вместо того чтобы делать это каждый раз, намного проще иметь классы HTMLView и JSONView. Или даже класс XMLView для отсылки XML-данных браузеру. Особенно это полезно, когда вы создаете большой сайт – у вас есть шаблоны и вам не нужно много раз копировать-вставлять один и тот же код.

Вот spec для /views/Base.js:

var View = require("../views/Base");
describe("Base view", function() {
    it("создает и отображает новый вид", 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("должна быть расширяемой", 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'});
    });
});

Чтобы протестировать вывод, мне пришлось создать объект mockup. В данном случае, я создал объект, который имитирует Express’овский объект response. Во второй части теста, я создал другой класс View, который наследует модель Base и применяет кастомный метод render. Вот это класс — /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);
        }
    }
}

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

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

Контроллер (Controller)

Помните маршруты (routes) и как мы их определили?

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

Символ ‘/’, в примере выше, это и есть контроллер. Это middleware-функция, которая принимает request, response и next.

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

В примере выше показано, как должен выглядеть ваш контроллер, в контексте Express. Команда express создает папку с именем routes, но в нашем случае, лучше назвать её controllers. Поэтому я переименовал её таким образом, чтобы отразить используемую нами схему MVC.

Так как создаваемое нами приложение является достаточно серьезным, будет мудрым решением создать класс base, который затем может быть расширен. Если нам когда-нибудь понадобиться передать некоторую функциональность другим контроллерам, то этот класс сослужит нам большую пользу. И снова, я сначала напишу тест, так что давайте определимся, что нам нужно от класса:

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

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

var BaseController = require("../controllers/Base");
describe("Base controller", function() {
    it("должен иметь метод extend, который возвращает дочерний экземпляр", 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("должен уметь создавать различные дочерние экземпляры", 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. Представим, что сайт разделен на две части – лицевая (front-end) и административная (back-end).

Лицевая будет использоваться для отображения пользователям информации, имеющейся в базе данных. Административная панель же будет использоваться для управления этими данными. Начнем с административной (контрольной) панели.

Контрольная панель

Для начала, давайте создадим простой контроллер, который будет обслуживать административную страницу и находиться в файле ./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: 'Административная панель',
            content: 'Добро пожаловать в административную панель'
        });
    }
});

Используя заранее написанные базовые классы для контроллеров и видов, мы легко можем создать точку входа в административную панель. Класс 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 должна быть защищена. Для этого, нам нужно использовать middleware, встроенное в Express, под названием Sessions. Этот инструмент просто прикрепляет объект к запросу названному session. Теперь нам нужно изменить контроллер нашей административной панели таким образом, чтобы он делал две вещи:

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

Вот небольшая helper-функция, которую мы можем использовать, чтобы реализовать это:

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: 'Административная панель',
                content: 'Добро пожаловать в административную панель'
            });
        });         
    } else {
        var v = new View(res, 'admin-login');
        v.render({
            title: 'Пожалуйста, представьтесь'
        });
    }       
}

Управление контентом

Как я пояснил выше, у нас есть много объектов, которые нужно администрировать. Чтобы упростить этот процесс, давайте оставим все данные в одной коллекции. Каждая запись будет иметь название, произвольный текст, картинку и свойство type.

Свойство type будет определять владельца данной записи. Например, для страницы «Контакты» будет нужна только одна запись type: ‘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: "Контакты",
    text: "...",
    type: "contacts"
});

Итак, у нас есть отличный API для управления данными в нашей коллекции mongodb. Теперь мы готовы к созданию пользовательского интерфейса для имеющейся функциональности. Для этого нужно будет немного изменить контроллер административной панели.

Для упрощения задачи я решил совместить список добавленных записей и форму для их добавления/редактирования. Как вы можете увидеть на скриншоте ниже, левая часть страницы зарезервирована под список, а правая – под форму.

контроллер административной панели

Расположив все на одной странице, мы сможем сфокусироваться на реализации вывода страницы и других специфических вещах, например, данных пересылаемых в шаблон. По этой причине, я создал несколько helper-функций, объединенных между собой вот так:

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: 'Административная панель',
                content: 'Добро пожаловать в административную панель',
                list: listMarkup,
                form: formMarkup
            });
        });
    });
});

Наша административная панель выглядит очень угловато, но работает именно так, как задумывалось. Первая helper-функция это метод del, который проверяет текущие параметры GET и, если находит строку action=delete&id=[id записи], то удаляет данные из коллекции.

Вторая функция называется 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/wp-content/uploads/" + uid;
    fs.mkdirSync(dir, '0777');
    fs.writeFileSync(dir + "/" + fileName, data);
    return '/wp-content/uploads/' + uid + "/" + fileName;
}

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

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

Это значит, что мы можем получить доступ к отправленным данным через req.files.picture. В коде, приведенном выше, req.files.picture.path используется, чтобы получить необработанное содержимое файла.

Позже, в те же данные записывается новый каталог и в конце возвращается URL-адрес. Все эти операции синхронны, но очень полезно использовать асинхронные версии readFileSync, mkdirSync и writeFileSync.

Лицевая часть (Front-End)

Самая сложная часть работы выполнена. Административная панель работает и у нас есть класс ContentModel, который дает доступ к информации, сохраненной в базе данных. Теперь нам нужно реализовать контроллеры фронт-энда и привязать их к сохраненному содержимому.

Ниже представлен контроллер для домашней страницы — /controllers/Home.js:

module.exports = BaseController.extend({ 
    name: "Домашняя страница",
    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) {
            ... здесь идет сохранение данных в объект content
            model.getlist(function(err, records) {
                ... здесь идет сохранение данных в объект content 
                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.

Это оптимальный способ, когда имеется только один контроллер, который принимает значение 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 это достаточно молодая платформа, и не все может работать, как ожидается, но улучшения делаются постоянно. Например, CLI-инструмент forever гарантирует, что ваше Node.js-приложение будет запущено вечно. Это делается командой:

forever start yourapp.js

Я использую это на всех своих серверах. Это отличный инструмент, решающий множество проблем. Если вы запускаете свою программу с помощью node yourapp.js, то после неожиданного завершения её работы, сервер упадет. Forever, просто перезапускает приложение в этом случае.

Я не системный администратор, но у меня есть желание поделиться своим опытом интеграции node-приложений с Apache и Nginx, потому что я считаю, что это часть рабочего процесса и помогает развитию программного обеспечения в принципе.

Как вы знаете, Apache нормально работает на 80 порту, а это означает, что если вы перейдете по адресу http://localhost или http://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 проще и, честно говоря, он лучше подходит для Nodejs-приложений. Первым шагом все также нужно добавить наш домен в файл 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 с настройками hosts-файлов, приведенными выше, потому что они требуют порт 80. Также, если вы системный администратор, то, скорее всего, захотите поэкспериментировать с настройками для улучшения производительности. Но повторюсь, я не эксперт в этой области.

Заключение

Express это замечательный фреймворк, который хорошо подходит для того, чтобы начать создавать свои веб-приложения. И, как вы могли заметить, результат будет зависеть от того, каким образом вы построите его, и что будете использовать при создании.

Express упрощает решение рутинных задач, он предоставляет отличное middleware и, в то же время, оставляет реализацию ответственных частей кода разработчику.

Исходный код

Исходные коды для данной статьи доступны на GitHub. Используйте их и экспериментируйте.

Вот краткая инструкция по запуску сайта:

  • Скачайте исходные коды;
  • Перейдите в папку app;
  • Запустите npm install;
  • Запустите демон mongodb;
  • Запустите команду node app.js.

Перевод статьи «Build a Complete MVC Website With ExpressJS» был подготовлен дружной командой проекта Сайтостроение от А до Я.