Advertisement
  1. Code
  2. Express

Створюємо повноцінний веб-сайт, працюючий на основі зразу MVC, за допомогою ExpressJS

Scroll to top
Read Time: 31 min

() translation by (you can also view the original English article)

У цьому посібнику ми будемо створювати повноцінний веб-сайт, що складається з призначеної для кінцевого користувача клієнтської частини та панелі керування (* представлена на екрані у вигляді набору піктограм утиліт, які дозволяють настроювати ті або інші функції ОС, пристрої або підсистеми комп'ютера. Тут і надалі примітка перекладача) для керування контентом сайту. Як ви, певно, здогадуєтеся, остаточна робоча версія додатка містить безліч різних файлів. Я написав цей посібник крок за кроком, слідуючи процесу розробки, проте у ньому не розглядається кожний окремий файл, оскільки у цьому випадку посібник став би дуже довгим та скучним. Проте початковий код доступний на GitHub, і я вам дуже раджу вам з ним ознайомитися.


Вступ

Express – один з найкращих фреймворків для Node.js. Для нього створено безліч модулів, і він має набір корисних можливостей. Окрім цього посібника є багато інших чудових посібників, в яких розкриваються всі головні питання розглядуваної у цьому посібнику теми. Проте тут я хочу копнути трохи глибше та поділитися інформацією про те, як у мене організовано процес розроблення повноцінного веб-сайту. У цілому цей посібник присвячений не тільки Express, але й використанню цього фреймворка сумісно з іншими чудовими інструментами, доступними для розробників, працюючих з платформою Node.

Я припускаю, що ви вже знайомі з Node.js, що її вже встановлено на вашому комп'ютері та що ви вже, ймовірно, створили за допомогою неї деякі додатки.

Express працює на основі Connect (* розширюваний фреймворк для створення сервера HTTP для Node.js, в якому використовуються «плаґіни» – ПЗ проміжного шару; починаючи з версії 4.x Express більше не залежить від Connect). Це фреймворк, що надає можливість використання ПЗ проміжного шару (* спосіб інкапсуляції функціональності, особливо функціональності, що працює з HTTP-запитом до вашого додатка. На ділі ПЗ проміжного шару — просто функція, що приймає три аргументи: об'єкт запиту, об'єкт відповіді та функцію next), що містить безліч корисних функціональних можливостей. Якщо ви неясно уявляєте, що таке ПЗ проміжного шару, то ось вам невеликий приклад:

1
var connect = require('connect'),
2
    http = require('http');
3
4
var app = connect()
5
    .use(function(req, res, next) {
6
        console.log("That's my first middleware");
7
        next();
8
    })
9
    .use(function(req, res, next) {
10
        console.log("That's my second middleware");
11
        next();
12
    })
13
    .use(function(req, res, next) {
14
        console.log("end");
15
        res.end("hello world");
16
    });
17
18
http.createServer(app).listen(3000);

ПЗ проміжного шару по суті є функцією, що приймає об'єкти запиту та відповіді і функцію next. За допомогою кожного ПЗ проміжного шару може бути відправлено відповідь завдяки використанню об'єкта response або ж передано керування наступній функції завдяки виклику функції зворотного виклику next. Якщо ви видалите у прикладі вище виклик методу next() у другому ПЗ проміжного шару, рядок «hello world» ніколи не буде відправлено браузеру. Ось так, у цілому, працює Express. Є деяке вбудоване ПЗ проміжного шару, яке, звісно ж, економить вам багато часу (* в Express 4 усі вбудоване ПЗ проміжного шару видалено, за виключенням express.static). Наприклад Body parser, за допомогою якого розбираються тіла запитів і який підтримує наступні типи контенту: application/json, application/x-www-form-urlencoded та multipart/form-data. Або ж Cookie parser, за допомогою якого розбирається заголовок Cookie та встановлюється в якості значення req.cookies об'єкт, ключами якого є назви кукі.

Express, власне, обгортає Connect та додає до нього нові функціональні можливості. Як, наприклад, логіку маршрутизації, завдяки чому процес оброблення запитів набагато полегшується. Нижче наведено приклад оброблення запиту за методом GET.

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

Встановлення

Є два головних способи встановлення Express. Перший реалізується завдяки вказанню його в якості залежності у вашому файлі package.json та виконанню команди npm install (є жарт, що npm означає no problem man (* без проблем, чувак) :)):

1
{
2
    "name": "MyWebSite",
3
    "description": "My website",
4
    "version": "0.0.1",
5
    "dependencies": {
6
        "express": "3.x"
7
    }
8
}

Код фреймворка буде поміщено до папки node_modules, і ви зможете створити його екземпляр. Проте я віддаю перевагу альтернативному варіанту, при якому використовується командний рядок. Просто встановіть Express глобально (* завдяки чому модуль може бути використано в якості інструмента командного рядка незалежно від того, в якій директорії ви знаходитесь) за допомогою команди npm install -g express. Завдяки цьому тепер ми маємо зовсім новий інструмент командного рядка. Наприклад, якщо ви виконаєте наступну команду:

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

то за допомогою Express буде створено каркас додатка із заздалегідь налаштованими для вас деякими моментами. Нижче наведено опції, якими можна скористатися при виконанні команди express(1):

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

Як ви бачите, доступно лише декілька опцій, проте мені їх достатньо. Звичайно я використовую less в якості препроцесора CSS та hogan в якості шаблонізатора (* ПЗ для комбінування шаблонів з моделлю даних для отримання кінцевих документів). У нашому прикладі також буде потрібна підтримка сесій, що вирішується завдяки додаванню аргумента --sessions. Після завершення виконання вищевказаних команд наш проект виглядає наступним чином:

1
/public
2
    /images
3
    /javascripts
4
    /stylesheets
5
/routes
6
    /index.js
7
    /user.js
8
/views
9
    /index.hjs
10
/app.js
11
/package.json

Якщо ви ознайомтеся з файлом package.json, то побачите, що всі потрібні нам залежності додано до нього. Проте їх ще не було встановлено. Для цього просто виконайте npm install, і після цього вони будуть додані до папки node_modules.

Я розумію, що вищезазначений спосіб не завжди підходить. Можливо, ви хочете помістити ваші обробники маршрутів до іншої папки або змінити щось ще. Але як ви побачите у наступних декількох частинах, я внесу зміни до вже згенерованої структури, що дуже просто виконати. Так що ви повинні просто розглядати команду express(1) в якості команди для створення зразка.


FastDelivery

Для цього посібника я створив простий веб-сайт вигаданої компанії під назвою FastDelivery. Нижче наведено скриншот завершеного дизайну:

sitesitesite

Під кінець посібника ми будемо мати готовий веб-додаток з робочою панеллю керування. Ідея полягає у тому, щоб реалізувати можливість керування кожною частиною додатка в окремих призначених тільки для певного кола користувачів областях. Макет було створено у Photoshop та зверстано за допомогою коду CSS(less) та HTML(hogan). Тепер ось що: я не буду розглядувати процес верстання, оскільки це не стосується обговорюваної у цьому посібнику теми, проте якщо ви маєте якісь питання з цього приводу, то сміло запитуйте. Після виконання верстання ми маємо наступні файли та структуру додатка:

1
/public
2
    /images (there are several images exported from Photoshop)
3
    /javascripts
4
    /stylesheets
5
        /home.less
6
        /inner.less
7
        /style.css
8
        /style.less (imports home.less and inner.less)
9
/routes
10
    /index.js
11
/views
12
    /index.hjs (home page)
13
    /inner.hjs (template for every other page of the site)
14
/app.js
15
/package.json

Нижче наведено список частин сайту, якими ми будемо керувати:

  • Home (* Головна сторінка) (банер (заголовок) посередині та текст)
  • Blog (* Блог) (для додання, видалення та редагування статей)
  • Services page (* Послуги)
  • Careers page (* Вакансії)
  • Contacts page (* Контакти)

Налаштування

Нам потрібно виконати дещо перед тим, як почати власне реалізацію додатка. Мова йде про налаштування конфігурації (* набір апаратних та/або програмних налаштувань (наприклад, положень перемикачів, значень змінних, керуючих послідовностей), визначаючих склад системи або додатка, функціональні можливості та/або режими функціонування пристрою або програми) додатка. Давайте уявимо, що наш невеликий додаток повинен бути доставлений у три різні місця – локальний сервер, допоміжний сервер (* служить для попереднього перегляду та перевірки нового контента перед публікацією на робочих серверах Мережі) та сервер для розміщення фінальної версії додатка. Зрозуміло, що налаштування для кожного середовища відрізняються та нам потрібно реалізувати достатньо гнучкий механізм для переходу між середовищами. Як ви знаєте, кожний скрипт node виконується в якості консольного застосування. Тому ми можемо з легкістю передати аргументи у командному рядку, за допомогою яких буде визначатися поточне середовище. Я оформив частину коду для налаштування конфігурації в якості окремого модуля, щоб написати тест для неї пізніше. Нижче наводиться код файлу index.js, розташованого у папці /config/:

1
var config = {
2
    local: {
3
        mode: 'local',
4
        port: 3000
5
    },
6
    staging: {
7
        mode: 'staging',
8
        port: 4000
9
    },
10
    production: {
11
        mode: 'production',
12
        port: 5000
13
    }
14
}
15
module.exports = function(mode) {
16
    return config[mode || process.argv[2] || 'local'] || config.local;
17
}

Поки що у ньому використовуються тільки два налаштування: mode (* режим) та port (* порт). Як ви, напевно, здогадуєтесь, у додатку використовуються різні порти для різних серверів. Тому нам потрібно оновити точку входу (* адреса команди, з якої починається виконання якогось фрагменту коду. Звичайно говорять про точки входу у підпрограму, функцію, драйвер або процедуру) застосування в app.js.

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

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

1
node app.js staging

буде виведено повідомлення

1
Express server listening on port 4000

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


Тести

Я великий шанувальник розробки через тестування (* Test-driven development (TDD)) Я спробую розглянути всі використовувані у цьому посібнику головні класи (* модулі). Зрозуміло, що у випадку обговорення тестів для всього коду цей посібник був би занадто довгим, проте у цілому, саме так вам варто поступати при створенні ваших власних застосувань. Один з моїх улюблених фреймворків для тестування – jasmine. Звісно ж, він доступний у реєстрі npm:

1
npm install -g jasmine-node

Давайте створимо папку tests, в якій будуть знаходитися наші тести. Для початку ми протестуємо налаштування конфігурації. Файли з технічними характеристиками (* документ, що описує вимоги, яким мають відповідати продукт або послуга; детальний опис функцій будь-чого (наприклад, програми, стандарту)) повинні закінчуватися на .spec.js, так що файл повинен називатися config.spec.js.

1
describe("Configuration setup", function() {
2
    it("should load local configurations", function(next) {
3
        var config = require('../config')();
4
        expect(config.mode).toBe('local');
5
        next();
6
    });
7
    it("should load staging configurations", function(next) {
8
        var config = require('../config')('staging');
9
        expect(config.mode).toBe('staging');
10
        next();
11
    });
12
    it("should load production configurations", function(next) {
13
        var config = require('../config')('production');
14
        expect(config.mode).toBe('production');
15
        next();
16
    });
17
});

Виконайте команду jasmine-node ./tests, і ви повинні будете побачити наступне:

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

Цього разу я написав спочатку реалізацію і потім тест до неї. Це не зовсім відповідає принципам розробки через тестування, проте у наступних декількох розділах я буду робити навпаки.

Я дуже вам раджу витратити значну кількість часу на написання тестів. Нема нічого кращого, ніж повністю протестований додаток.

Декілька років тому я усвідомив дещо важливе, що може допомогти вам розроблювати якісніші програми. Кожного разу, коли починаєте писати новий клас, новий модуль або просто новий фрагмент логіки, запитайте себе:

Як мені це протестувати?

Відповідь на це запитання допоможе вам підвищити ефективність при написанні коду, створювати кращі API та оформлювати весь код у вигляді акуратно відокремлених блоків. Ви не зможете написати тести для заплутаного коду (* будь-яка погано спроектована, слабко структурована й важка для розуміння програма). Наприклад, у конфігураційному файлі вище (/config/index.js) я додав можливість передачі mode до конструктора модуля. Ви, напевно, на розумієте, навіщо я це роблю, коли головна ідея – отримати значення mode із аргументів командного рядка. Все просто... тому що мені потрібно було протестувати ту функціональну можливість. Давайте уявимо, що місяць по тому мені потрібно перевірити дещо у конфігурації, призначеній для фінальної версії додатка (production), але при цьому запускати скрипт node з параметром staging. Я би не зміг це виконати без того незначного вдосконалення. Той попередній невеликий крок тепер по суті позбавляє мене від проблем у майбутньому.


База даних

Оскільки ми створюємо динамічний веб-сайт, нам потрібна база даних для зберігання у ній наших даних. Я вирішив використовувати mongodb для цього посібника. Mongo – документальна база даних (* упорядкована сукупність взаємопов'язаних документів; база даних, в якій кожний запис відповідає конкретному документу, містить його опис та, можливо, іншу інформацію про нього) NoSQL. Інструкції з встановлення можуть бути знайдені тут, і оскільки я користуюсь Windows, то я замість цього скористався інструкціями для неї. Одразу після встановлення запустіть демон (* прихований від користувача процес (часто виконуваний у фоновому режимі), який викликають під час виконання якоїсь функції (або в конкретний момент часу)) MongoDB, що за налаштуванням прослуховує підключення по 27017 порту. Що ж, теоретично тепер ми повинні мати можливість підключитися до цього порту та обмінятися інформацією з сервером mongodb. Для виконання цього у скрипті node нам потрібний модуль/драйвер mongodb. Якщо ви завантажили файли з початковим кодом для цього посібника, то модуль mongodb вже додано у файлі package.json. Якщо ж ні, то просто додайте "mongodb": "1.3.10" до списку ваших залежностей та виконайте команду npm install.

Далі ми напишемо тест для перевірки того, чи запущено сервер mongodb. Нижче наведено контент файлу mongodb.spec.js, що знаходиться у папці /tests/:

1
describe("MongoDB", function() {
2
    it("is there a server running", function(next) {
3
        var MongoClient = require('mongodb').MongoClient;
4
        MongoClient.connect('mongodb://127.0.0.1:27017/fastdelivery', function(err, db) {
5
            expect(err).toBe(null);
6
            next();
7
        });
8
    });
9
});

Функція зворотного виклику у методі .connect клієнта mongodb отримує об'єкт db. Ми будемо використовувати його пізніше для керування нашими даними, і це означає, що нам потрібен буде доступ до нього всередині наших модулів. Нерозумно створювати новий об'єкт MongoClient кожного разу, коли нам потрібно виконати запит до бази даних. Тому я перемістив код для запуску сервера express до функції зворотного виклику функції connect:

1
MongoClient.connect('mongodb://127.0.0.1:27017/fastdelivery', function(err, db) {
2
    if(err) {
3
        console.log('Sorry, there is no mongo db server running.');
4
    } else {
5
        var attachDB = function(req, res, next) {
6
            req.db = db;
7
            next();
8
        };
9
        http.createServer(app).listen(config.port, function(){
10
            console.log('Express server listening on port ' + config.port);
11
        });
12
    }
13
});

Ми можемо вдосконалити код ще далі. Оскільки ми маємо налаштування конфігурації, то було би чудово помістити туди значення хоста та порту і потім змінити URL, використовуваний для підключення до бази даних, на:

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

Зверніть увагу на ПЗ проміжного шару attachDB, яке я додав одразу перед викликом функції http.createServer. Завдяки цьому невеликому доповненню ми присвоїмо значення властивості .db об'єкта запиту. Хороші новини полягають у тому, що ми можемо підключити декілька функцій при визначення маршруту. Наприклад:

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

Так що завдяки цьому Express викликає attachDB перед виконанням нашого обробника маршруту. Одразу після цього об'єкту запиту буде додано властивість .db, і ми можемо його використовувати для доступу до бази даних.


MVC

Всі ми знайомі зі зразком MVC (* Model-View-Controller – Модель-Представлення-Контролер. Архітектурний зразок програмного забезпечення, який звичайно використовується для розробки користувальницьких інтерфейсів, згідно з яким додаток поділяється на три частини). Питання полягає у тому, як це стосується Express. У цілому це залежить від інтерпретації. У наступних декількох розділах я створю модулі, які будуть виконувати роль моделі, представлення та контролера

Модель

Модель – те, за допомогою чого ми будемо керувати наявними у нашому додатку даними. Вона повинна мати доступ до об'єкта db, який повертає MongoClient. У нашій моделі також повинен бути метод для її розширення, оскільки ми можемо захотіти створити різні типи моделей. Наприклад, ми би могли захотіти створити BlogModel або ContactsModel. Так що нам потрібно написати нові технічні характеристики – /tests/base.model.spec.js для того, щоб протестувати ці дві можливості моделі. І пам'ятайте, що завдяки визначенню цих функціональних можливостей перед їх реалізацією ми можемо гарантувати, що наш модуль буде виконувати тільки те, що нам потрібно.

1
var Model = require("../models/Base"),
2
    dbMockup = {};
3
describe("Models", function() {
4
    it("should create a new model", function(next) {
5
        var model = new Model(dbMockup);
6
        expect(model.db).toBeDefined();
7
        expect(model.extend).toBeDefined();
8
        next();
9
    });
10
    it("should be extendable", function(next) {
11
        var model = new Model(dbMockup);
12
        var OtherTypeOfModel = model.extend({
13
            myCustomModelMethod: function() { }
14
        });
15
        var model2 = new OtherTypeOfModel(dbMockup);
16
        expect(model2.db).toBeDefined();
17
        expect(model2.myCustomModelMethod).toBeDefined();
18
        next();
19
    })
20
});

Замість передачі справжнього об'єкта db я вирішив передати його імітацію. Я це зробив, оскільки мені може знадобитися протестувати щось конкретне, що залежить від інформації, яка надходить з бази даних. Буде набагато простіше визначити ці дані вручну.

Реалізувати метод extend трохи складнувато, оскільки нам потрібно змінити прототип module.exports, але як і раніше зберегти початковий конструктор. На щастя, у нас вже написано чудовий тест, за допомогою якого підтверджується, що наш код працює. Версія модуля, яка проходить вищевказаний тест, виглядає наступним чином:

1
module.exports = function(db) {
2
    this.db = db;
3
};
4
module.exports.prototype = {
5
    extend: function(properties) {
6
        var Child = module.exports;
7
        Child.prototype = module.exports.prototype;
8
        for(var key in properties) {
9
            Child.prototype[key] = properties[key];
10
        }
11
        return Child;
12
    },
13
    setDB: function(db) {
14
        this.db = db;
15
    },
16
    collection: function() {
17
        if(this._collection) return this._collection;
18
        return this._collection = this.db.collection('fastdelivery-content');
19
    }
20
}

Тут ми маємо два допоміжних методи. Сеттер для об'єкта db та геттер для нашої колекції бази даних.

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

Представлення відобразить інформацію на екран. По суті, представлення – клас, що відправляє відповідь браузеру. Express надає легкий спосіб для виконання цього:

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

Об'єкт відповіді – обгортка з чудовим API, що полегшує нам життя. Проте я би краще створив модуль, що інкапсулював би цей функціонал: Ми замінимо задану за налаштуванням папку для представлень views на templates та створимо нове представлення, в якому буде розташовуватися клас представлення Base. Для виконання цієї невеликої зміни потрібно внести ще одну. Нам потрібно вказати Express, що наші файли шаблонів тепер розташовуються в іншій папці:

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

Для початку я опишу те, що мені потрібно, напишу тест та після цього напишу код для реалізації обговорюваного тут функціоналу. Нам потрібен модуль, що задовольняє наступним вимогам:

  • Його конструктор повинен отримати об'єкт відповіді та ім'я зразка.
  • Він повинен мати метод render, що приймає об'єкт з даними.
  • У нього повинна бути можливість розширення.

Вам, ймовірно, на зовсім зрозуміло, навіщо я розширюю клас View. Чи не служить він просто для виклику методу response.render? Що ж, на практиці є класи, в яких ви захочете відправити інший заголовок або, можливо, змінити об'єкт response якимось чином. Як, наприклад, при відправленні даних у форматі JSON:

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

Замість того щоб виконувати це кожного разу, було би чудово, якщо би мали класи JSONView та HTMLView. Або навіть клас XMLView для відправлення браузеру даних у форматі XML. Просто краще, якщо ви створюєте великий веб-сайт, обгорнути такі функціональні можливості, а не копіювати та вставляти той самий код знову та знову.

Нижче наведено опис вимог, пред'являємих до розташованого у папці /views/ Base.js:

1
var View = require("../views/Base");
2
describe("Base view", function() {
3
    it("create and render new view", function(next) {
4
        var responseMockup = {
5
            render: function(template, data) {
6
                expect(data.myProperty).toBe('value');
7
                expect(template).toBe('template-file');
8
                next();
9
            }
10
        }
11
        var v = new View(responseMockup, 'template-file');
12
        v.render({myProperty: 'value'});
13
    });
14
    it("should be extendable", function(next) {
15
        var v = new View();
16
        var OtherView = v.extend({
17
            render: function(data) {
18
                expect(data.prop).toBe('yes');
19
                next();
20
            }
21
        });
22
        var otherViewInstance = new OtherView();
23
        expect(otherViewInstance.render).toBeDefined();
24
        otherViewInstance.render({prop: 'yes'});
25
    });
26
});

Для того щоб протестувати роботу представлення, я повинен був створити імітацію об'єкта. У цьому випадку я створив об'єкт, що імітує об'єкт відповіді Express. У другій частині тесту я створив другий клас View, що наслідує функціонал від базового та використовує свій власний метод render. Нижче наведено код для класу, описуваного у файлі Base.js, розташованому у папці /views/:

1
module.exports = function(response, template) {
2
    this.response = response;
3
    this.template = template;
4
};
5
module.exports.prototype = {
6
    extend: function(properties) {
7
        var Child = module.exports;
8
        Child.prototype = module.exports.prototype;
9
        for(var key in properties) {
10
            Child.prototype[key] = properties[key];
11
        }
12
        return Child;
13
    },
14
    render: function(data) {
15
        if(this.response && this.template) {
16
            this.response.render(this.template, data);
17
        }
18
    }
19
}

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

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

Контролер

Пам'ятаєте маршрути та як їх було визначено?

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

Те, що йде після '/' у вищезазначеному прикладі маршруту, є власне контролером. Це просто функція ПЗ проміжного шару, що приймає request, response та next.

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

Вище показано, як повинен виглядати ваш контролер у контексті Express. Інструмент командного рядка express(1) створює паку під назвою routes, проте у нашому випадку більш вдале ім'я – controllers, так що я змінив її назву, щоб вона відповідала вимогам потрібної нам схеми найменування.

Оскільки ми створюємо не просто крихітний додаток, то було би розумно створити базовий клас, який ми потім можемо розширювати. Якщо нам буде потрібно колись додати якийсь функціонал всім нашим контролерам, то цей базовий клас був би ідеальним місцем. Знов-таки, я напишу спочатку тест, так що давайте визначимося з тим, що нам потрібно:

  • клас повинен мати метод extend, який приймає об'єкт та повертає новий дочірній зразок;
  • дочірній зразок повинен мати метод run – стару функцію ПЗ проміжного шару;
  • клас повинен мати властивість name, що служить для ідентифікації контролера;
  • ми повинні мати можливість створити незалежні об'єкти на основі цього класу;

Що ж, всього-на-всього декілька вимог поки що, проте пізніше ми можемо додати додаткові. Тест виглядав би приблизно наступним чином:

1
var BaseController = require("../controllers/Base");
2
describe("Base controller", function() {
3
    it("should have a method extend which returns a child instance", function(next) {
4
        expect(BaseController.extend).toBeDefined();
5
        var child = BaseController.extend({ name: "my child controller" });
6
        expect(child.run).toBeDefined();
7
        expect(child.name).toBe("my child controller");
8
        next();
9
    });
10
    it("should be able to create different childs", function(next) {
11
        var childA = BaseController.extend({ name: "child A", customProperty: 'value' });
12
        var childB = BaseController.extend({ name: "child B" });
13
        expect(childA.name).not.toBe(childB.name);
14
        expect(childB.customProperty).not.toBeDefined();
15
        next();
16
    });
17
});

А нижче наведено реалізацію розташованого у папці /controllers/ Base.js:

1
var _ = require("underscore");
2
module.exports = {
3
    name: "base",
4
    extend: function(child) {
5
        return _.extend({}, this, child);
6
    },
7
    run: function(req, res, next) {
8
9
    }
10
}

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


Веб-сайт FastDelivery

Що ж, ми маємо добрий набір класів для нашої архітектури, що задовольняє вимогам MVC, і ми розглянули наші недавно створені модулі з тестами. Тепер ми готові продовжити розробку сайту нашої вигаданої компанії FastDelivery. Давайте уявимо, що у сайту є дві частини: клієнтська частина та адміністративна панель. Клієнтська частина буде використовуватися для відображення записаної у базі даних інформації нашим кінцевим користувачам. Адміністративна панель буде використовуватися для керування тими даними. Давайте для початку розберемося з нашою адміністративною панеллю (панеллю керування).

Панель керування

Давайте для початку створимо простий контролер, що буде використовуватися для створення сторінки для адміністратора. Вміст файлу Admin.js, розташованого у папці /controllers/, наводиться нижче:

1
var BaseController = require("./Base"),
2
    View = require("../views/Base");
3
module.exports = BaseController.extend({ 
4
    name: "Admin",
5
    run: function(req, res, next) {
6
        var v = new View(res, 'admin');
7
        v.render({
8
            title: 'Administration',
9
            content: 'Welcome to the control panel'
10
        });
11
    }
12
});

Завдяки використанню заздалегідь написаних базових класів для наших контролерів та представлень ми можемо з легкістю створити точку входу для панелі керування. Клас View приймає ім'я файлу зразка. Згідно з кодом вище файл повинен називатися admin.hjs та повинен розташовуватися у папці /templates. Контент зразка виглядав би приблизно наступним чином:

1
<!DOCTYPE html>
2
<html>
3
    <head>
4
        <title>{{ title }}</title>
5
        <link rel='stylesheet' href='/stylesheets/style.css' />
6
    </head>
7
    <body>
8
        <div class="container">
9
            <h1>{{ content }}</h1>
10
        </div>
11
    </body>
12
</html>

(Для того щоб цей посібник залишався відносно коротким та у зручному для читання форматі, я не буду тут розглядати всі зразки представлень. Я вам дуже раджу завантажити початковий код з GitHub)

Тепер для застосування розглянутого вище контролера нам потрібно додати для нього маршрут в app.js:

1
var Admin = require('./controllers/Admin');
2
...
3
var attachDB = function(req, res, next) {
4
    req.db = db;
5
    next();
6
};
7
...
8
app.all('/admin*', attachDB, function(req, res, next) {
9
    Admin.run(req, res, next);
10
});

Зверніть увагу, що ми не пересилаємо метод Admin.run безпосередньо в якості ПЗ проміжного шару. Це так, оскільки ми хочемо зберегти вірний контекст. Якщо ми зробимо наступне

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

то ключове слово this в Admin буде вказувати на якийсь інший контекст.

Захищаємо панель керування

Кожна сторінка, шлях якої починається з /admin, повинна бути захищена. Для цього ми будемо використовувати ПЗ проміжного шару Express для реалізації сесій. За допомогою нього просто приєднується об'єкт до об'єкта запиту під назвою session. Тепер нам потрібно змінити наш контролер Admin для виконання двох додаткових завдань:

  • він повинен перевірити наявність властивості session; за його відсутності повинна бути передана форма для реєстрації;
  • він повинен прийняти відправлені у формі дані та авторизувати користувача при збігу імені користувача та паролю;

Нижче наведено невелику допоміжну функцію, яку ми можемо використовувати для виконання вищезазначених вимог:

1
authorize: function(req) {
2
    return (
3
        req.session && 
4
        req.session.fastdelivery && 
5
        req.session.fastdelivery === true
6
    ) || (
7
        req.body &&
8
        req.body.username === this.username &&
9
        req.body.password === this.password
10
    );
11
}

Спочатку у нас є інструкція, за допомогою якої виконується спроба розпізнавання користувача з використанням об'єкта session. Потім ми перевіряємо, чи було надіслано форму. Якщо так, то дані форми стають доступними в об'єкті request.body, значення якого встановлюється за допомогою ПЗ проміжного шару bodyParser. Потім перевіряємо, чи співпадає ім'я користувача та пароль.

А тепер переходимо до методу run контролера, в якому використовується наш новий допоміжний метод. Ми перевіряємо, чи авторизовано користувача, і в залежності від результату відображуємо або саму панель керування, або сторінку для входу в систему.

1
run: function(req, res, next) {
2
    if(this.authorize(req)) {
3
        req.session.fastdelivery = true;
4
        req.session.save(function(err) {
5
            var v = new View(res, 'admin');
6
            v.render({
7
                title: 'Administration',
8
                content: 'Welcome to the control panel'
9
            });
10
        });         
11
    } else {
12
        var v = new View(res, 'admin-login');
13
        v.render({
14
            title: 'Please login'
15
        });
16
    }       
17
}

Керування контентом

Як згадав на початку посібника, нам потрібно керувати багатьма частинами веб-сайту. Для полегшення процесу давайте зберігати всі дані в одній колекції. Кожний запис буде мати властивості title, text, picture та type. За допомогою властивості type буде визначатися, до якої частини додатка належить запис. Наприклад, для сторінки Contacts буде потрібен тільки один запис з type: 'contacts', тоді як для сторінки Blog буде потрібно багато записів. Що ж, нам потрібні три сторінки для додавання, редагування та відображення записів. Перед тим, як ми приступимо до створення нових зразків, додаванню стильового оформлення та нових можливостей для контролера, нам потрібно написати наш клас для моделі, що знаходиться між сервером MongoDB та нашим додатком і, звісно ж, надає зрозумілий API.

1
// /models/ContentModel.js
2
3
var Model = require("./Base"),
4
    crypto = require("crypto"),
5
    model = new Model();
6
var ContentModel = model.extend({
7
    insert: function(data, callback) {
8
        data.ID = crypto.randomBytes(20).toString('hex'); 
9
        this.collection().insert(data, {}, callback || function(){ });
10
    },
11
    update: function(data, callback) {
12
        this.collection().update({ID: data.ID}, data, {}, callback || function(){ });   
13
    },
14
    getlist: function(callback, query) {
15
        this.collection().find(query || {}).toArray(callback);
16
    },
17
    remove: function(ID, callback) {
18
        this.collection().findAndModify({ID: ID}, [], {}, {remove: true}, callback);
19
    }
20
});
21
module.exports = ContentModel;

За допомогою цієї моделі для кожного запису генерується унікальний ID. Він нам стане у пригоді для оновлення інформації у подальшому.

Якщо нам потрібно додати новий запис для нашої сторінки Contacts, то ми можемо просто скористатися наступним кодом:

1
var model = new (require("../models/ContentModel"));
2
model.insert({
3
    title: "Contacts",
4
    text: "...",
5
    type: "contacts"
6
});

Що ж, ми маємо API для керування даними нашої колекції mongodb. Тепер ми готові до написання коду для UI (* інтерфейс користувача), щоб скористатися цим функціоналом. Переходимо до першої частини додатка. Контролер Admin потрібно буде змінити доволі сильно. Для полегшення нашої задачі я вирішив скомбінувати список доданих записів та форму для їх додання/редагування. Як ви бачите на скриншоті нижче, ліва частина сторінки відведена для списку, а права частина – для форми.

control-panelcontrol-panelcontrol-panel

Через те що все розташовується на одній сторінці, нам потрібно зосередитися на частині, що відповідає за створення сторінки, або, точніше, на даних, що відправляються зразку. Тому я створив декілька допоміжних функцій, які комбінуються наступним чином:

1
var self = this;
2
...
3
var v = new View(res, 'admin');
4
self.del(req, function() {
5
    self.form(req, res, function(formMarkup) {
6
        self.list(function(listMarkup) {
7
            v.render({
8
                title: 'Administration',
9
                content: 'Welcome to the control panel',
10
                list: listMarkup,
11
                form: formMarkup
12
            });
13
        });
14
    });
15
});

Цей код виглядає трохи страхітливо, але працює так, як нам це потрібно. Перший допоміжний метод – метод del, за допомогою якого перевіряються поточні параметри запиту за методом GET, і якщо він виявляє action=delete&id=[id of the record], то видаляє дані з колекції. Друга функція називається form та відповідає, головним чином, за відображення форми справа сторінки. За допомогою неї перевіряється, чи відправлено форму, і відповідним чином оновлюються або створюються записи у базі даних. У кінці коду метод list отримує інформацію та підготовлює HTML-таблицю, яка пізніше відправляється зразку. З реалізацією цих трьох допоміжних функцій ви можете ознайомитися у початковому коді для цього посібника.

Далі я вирішив показати вам функцію, за допомогою якої забезпечується вивантаження файлів:

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

Якщо файл було відправлено, то властивість .files об'єкта запиту скрипта node заповнюється даними. У нашому випадку ми маємо наступний елемент HTML:

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

Це означає, що ми могли би отримати доступ до надісланих даних за допомогою req.files.picture. У вищевказаному фрагменті коду req.files.picture.path використовується для отримання первинних даних файлу. Пізніше ті самі дані записуються до недавно створеної папки, і в кінці коду повертається відповідний URL. Всі ці операції виконуються синхронно, проте згідно з усталеною практикою слід використовувати асинхронний варіант методів readFileSync, mkdirSync та writeFileSync.

Клієнтська частина застосування

З найскладнішим ми тепер розібралися. Адміністративна панель працює, і ми маємо клас ContentModel, який надає нам доступ до інформації, що зберігається у базі даних. Тепер нам потрібно написати контролери для клієнтської частини та прив'язати їх до зберігаємих даних.

Нижче наведено код контролера для сторінки Home, розташований у файлі Home.js (папка /controllers/):

1
module.exports = BaseController.extend({ 
2
    name: "Home",
3
    content: null,
4
    run: function(req, res, next) {
5
        model.setDB(req.db);
6
        var self = this;
7
        this.getContent(function() {
8
            var v = new View(res, 'home');
9
            v.render(self.content);
10
        })
11
    },
12
    getContent: function(callback) {
13
        var self = this;
14
        this.content = {};
15
        model.getlist(function(err, records) {
16
            ... storing data to content object
17
            model.getlist(function(err, records) {
18
                ... storing data to content object
19
                callback();
20
            }, { type: 'blog' });
21
        }, { type: 'home' });
22
    }
23
});

Для Домашньої сторінки потрібен один запис типу home та чотири записи типу blog. Після реалізації контролера нам всього-на-всього потрібно додати маршрут для нього в app.js:

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

Знов-таки, ми додаємо об'єкт db до об'єкта запиту. Працюємо з цим контролером подібно до того, як працювали з адміністративною панеллю.

Решта сторінок для нашої клієнтської частини дуже подібна розглянутій вище тим, що для них всіх є контролер, завдяки якому витягуються потрібні дані за допомогою класу моделі, і, звичайно, створюється певний маршрут. При цьому є дві цікаві ситуації, які мені хотілося би обговорити більш детально. Перша пов'язана зі сторінкою для блога. Для цієї сторінки повинна бути можливість як відображення всіх статей, так і тільки однієї. Так що нам потрібно зареєструвати два маршрути:

1
app.all('/blog/:id', attachDB, function(req, res, next) {
2
    Blog.runArticle(req, res, next);
3
}); 
4
app.all('/blog', attachDB, function(req, res, next) {
5
    Blog.run(req, res, next);
6
});

У них обох використовується той самий контролер – Blog, проте викликаються різні методи run. Також зверніть увагу на рядок /blog/:id.. Цей маршрут буде оброблювати запити, виконувані за адресами на зразок /blog/4e3455635b4a6f6dccfaa1e50ee71f1cde75222b, і довгий фрагмент буде доступний у req.params.id. Іншими словами, ми маємо можливість визначення динамічних параметрів. У нашому випадку це ID запису. Після отримання цієї інформації ми можемо створити унікальну сторінку для кожної статті.

Другий цікавий момент полягає в тому, як я створив сторінки Services, Careers та Contacts. Зрозуміло, що вони використовують тільки один запис з бази даних. Якщо би нам потрібно було створити окремий контролер для кожної сторінки, то ми повинні були би копіювати/вставляти той самий код і просто змінювати значення поля type. Є, проте, більш вдалий спосіб досягнення цього з використанням тільки одного контролера, який приймає певне значення type та відповідний до нього метод run. Що ж, нижче наведено код для маршрутів:

1
app.all('/services', attachDB, function(req, res, next) {
2
    Page.run('services', req, res, next);
3
}); 
4
app.all('/careers', attachDB, function(req, res, next) {
5
    Page.run('careers', req, res, next);
6
}); 
7
app.all('/contacts', attachDB, function(req, res, next) {
8
    Page.run('contacts', req, res, next);
9
});

Ф контролер виглядав би приблизно наступним чином:

1
module.exports = BaseController.extend({ 
2
    name: "Page",
3
    content: null,
4
    run: function(type, req, res, next) {
5
        model.setDB(req.db);
6
        var self = this;
7
        this.getContent(type, function() {
8
            var v = new View(res, 'inner');
9
            v.render(self.content);
10
        });
11
    },
12
    getContent: function(type, callback) {
13
        var self = this;
14
        this.content = {}
15
        model.getlist(function(err, records) {
16
            if(records.length > 0) {
17
                self.content = records[0];
18
            }
19
            callback();
20
        }, { type: type });
21
    }
22
});

Розгортаємо застосування на сервері

Розгортання додатка, створеного на основі Express, власне, нічим не відрізняється від розгортання будь-якого іншого додатка Node.js.

  • Файли вивантажуються на сервер.
  • Процес (* набір з одного і більш трендів (потоків) і асоційованих із ними системних ресурсів) node повинен бути зупинений (якщо його запущено).
  • Повинно бути виконано команду npm install для встановлення нових залежностей (якщо такі є).
  • Головний скрипт повинен бути потім знову запущений.

Майте на увазі, що Node – досі доволі молода платформа, так що не все може працювати належним чином, проте вона постійно вдосконалюється. Наприклад, за допомогою forever гарантується, що ваша програма Node буде виконуватися постійно. Ви можете це виконати завдяки наступній команді:

1
forever start yourapp.js

Я також користуюся цим інструментом на моєму сервері. Це чудовий невеликий інструмент, який проте вирішує значну проблему. Якщо ви запускаєте ваш додаток просто завдяки команді node yourapp.js, то після раптового завершення роботи сервер перестає працювати. forever просто перезапускає застосування.

Тепер ось що: я не адміністратор, проте хотів би поділитися моїм досвідом інтегрування додатків Node з сервером Apache або Nginx, оскільки вважаю, що так чи інакше це відноситься до процесу розробки.

Як ви знаєте, Apache звичайно прослуховує підключення по 80 порту, і це означає, що якщо ви перейдете за адресою http://localhost або http://localhost:80, то побачите сторінку, відправлену сервером Apache, і, скоріше за все, скрипт вашого додатка node прослуховує підключення по іншому порту. Так що вам потрібно додати віртуальний хост, який приймає запити та відправляє їх на потрібний порт. Наприклад, давайте припустимо, що я хочу розмістити тільки-но створений мною сайт на моєму локальному сервері Apache, доступ до якого здійснюється за адресою expresscompletewebsite.dev. Для початку нам потрібно додати наш домен до файлу hosts.

1
127.0.0.1   expresscompletewebsite.dev

Далі нам потрібно відкрити файл httpd-vhosts.conf у папці, використовуваній для конфігурації Apache, та додати туди наступний код:

1
# expresscompletewebsite.dev
2
<VirtualHost *:80>
3
    ServerName expresscompletewebsite.dev
4
    ServerAlias www.expresscompletewebsite.dev
5
    ProxyRequests off
6
    <Proxy *>
7
        Order deny,allow
8
        Allow from all
9
    </Proxy>
10
    <Location />
11
        ProxyPass http://localhost:3000/
12
        ProxyPassReverse http://localhost:3000/
13
    </Location>
14
</VirtualHost>

Після цього сервер як і раніше прослуховує запити по 80 порту, проте буде пересилати їх на 3000 порт, що прослуховує додаток Node.

Встановлювати на Nginx набагато легше, і, правду кажучи, це більш вдалий варіант для розміщення додатків, розроблених за допомогою платформи Node.js. Вам як і раніше потрібно додати ім'я домену у вашому файлі hosts. Після цього просто створіть новий файл у папці /sites-enabled, розташованій у папці, де встановлено Nginx. Контент файлу виглядав би приблизно наступним чином:

1
server {
2
    listen 80;
3
    server_name expresscompletewebsite.dev
4
    location / {
5
            proxy_pass http://127.0.0.1:3000;
6
            proxy_set_header Host $http_host;
7
    }
8
}

Майте на увазі, що ви не можете запустити ні Apache, ні Nginx, використовуючи вищевказані налаштування хоста. Це так, оскільки для роботи цих обох серверів потрібен порт 80. Також вам можуть знадобитися додаткові відомості для більш вдалого налаштування конфігурації сервера, якщо ви плануєте використовувати обговорювані вище фрагменти коду у середовищі для виконання остаточної версії додатка. Як я сказав раніше, я не експерт у цій області.


Завершення

Express – чудовий фреймворк, що надає вам хорошу відправну точку для створення додатків. Як ви розумієте, те, як ви його розширите та що будете використовувати при роботі з ним, залежить від вас. За допомогою нього полегшується виконання рутинних задач завдяки використанню деякого ПЗ проміжного шару, а цікава частина роботи залишається розробникові.

Початковий код

Початковий код для прикладу створеного нами у цьому посібнику сайту доступний на GitHub - https://github.com/tutsplus/build-complete-website-expressjs. За бажанням можете форкнути його та поекспериментувати з ним. Нижче перелічено кроки для запуску сайту:

  • Завантажте початковий код
  • Перейти до папки app
  • Виконайте команду npm install
  • Виконайте команду mongodb daemon
  • Виконайте команду node app.js
Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.