Привет, ребята! С этого момента я хочу начать цикл статтей с подробностями по созданию сообственного MVC фреймворка для
node.js
, название которому будет — Spirit.Первая статья будет состять из четырех частей:
1. Идея и миссия фреймворка
2. Настройка сервера
3. Создание каркаса фреймворка
4. Создание продвинутого и удобного роутера
Предупреждаю сразу, что статья — огромна, с кучей текста и большими блоками кода.
Идея и миссия фреймворка
Спирит будет развиватся неторопливо, по мере появления вдохновения, настроения и времени (которого сейчас немного). Хотя критика и предложения приветствуются — я будут его развивать по сообственному видению и, если первая статья будет тепло вопринята сообществом — выкладывая детали каждого логического участка в виде статьи.
Основные цели — обучение и подогревание интереса к платформе. Я буду крайне рад, если кто-нибудь форкнет проект и сделает на его базе стоящий фреймворк, можно даже обратится ко мне за моральной поддержкой. Тем не менее, если вы хотите принять участие именно в развитии Spirit с детальным описанием своих действий, чем заработать себе положительную (реальную) карму — пишите на shocksilien@gmail.com, обсудим).
Весь исходный код будет доступен на ГитХабе по ссылке github.com/theshock/spirit
Хотя я буду старатся описывать детали, стиль изложения подразумевает, что читатель знает хотя-бы основы администрирования, програмирования на javascript и node.js в частности и имеет представление о CMF. Кое-где, чтобы разгрузить статью, я вырезал куски кода, оставляя только комментарии, надеюсь, вы додумаете их сами. В любом случае — полный и работающий пример есть на ГитХабе.
В примерах я предполагаю, что мы используем систему Debian, а домашняя директория пользователя — "/home/spirit". Сайт будет располагатся на spirit.example.com, если не указано иначе
Настройка сервера
В отличии от nginx и apache node.js из коробки не автозапускается на сервере и необходимо провести некоторую настройку.
Сначала с помощью
init
создаем демона, а потом с помощью monit
проверяем, не упал ли сервер. Данная тема уже давно расписана, информацию можно найти в Гугле, например на сайте nodejs.ru. Такой подход проверен и одобрен лично мной на примере сокращателя ссылок на node.js, который за несколько часов сократил более 15000 ссылок и даже не дрогнул, стоит до сих пор (почти 20 дней).Второй шаг — это нгинкс в качестве фронтенда.
server { listen 80; server_name spirit.example.com; access_log /home/spirit/example-app/logs/nginx.access.log; location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8124/; } location ~* \.(html|js|ico|gif|jpg|png|css|doc|xml|rar|zip|swf|avi|mpg|flv|mpeg|wmv|ogv|oga|ogg)$ { root /home/spirit/example-app/public; } }
Мы видим очень простой код — nginx перенапрявляет все, кроме статики на наш node.js, который будет располагатся на порту 8124 (или любой другой, который вы укажете). Статика же будет отдаваться без какого-либо участия node.js напрямую нгинксом.
Создаём скелет фреймворка
Итак, мы будем стремится к такой структуре каталогов:
/home/spirit/
├ lib/
│ ├ mysql-libmysqlclient/
│ ├ spirit/
│ └ MooTools.js
├ example-app/
│ ├ engine/
│ │ ├ classes/
│ │ └ init.js
│ ├ logs/
│ └ public/
└ another-example-app/
├ engine/
│ ├ classes/
│ └ init.js
├ logs/
└ public/
В этой схеме можно заметить две ключевых идеи:
1. Все библиотеки, в т.ч. и Spirit будут находится в директориях отдельных от сообственно приложений — это позволит держать несколько приложений на одном сервере, не дублируя список всех библиотек.
2. Каждое приложение имеет две ключевые директории — engine, в которой мы будем хранить серверную логику и public, весь контент которой мы будем отдавать клиенту. Это позволит защитить серверный код от любых посягательств снаружи и разграничит различную логику.
В директорию
lib
мы будем сбрасывать все необходимые нам библиотеки.Spirit базируется на доработанном MooTools, как его подключить я рассказывал в топике про сокращатель ссылок. Для легкости подключения прям в файл
lib/MooTools.js
я подключил MooTools.More Class.Binds, что позволит передавать методы объектов в качестве аргументов не теряя контекста и немного расширил прототип строки, добавив методы htmlEscape
, ucfirst
и lcfirst
.Инициализация приложения
Подключение (require) библиотек в коде присходит одним из следующих методов:
1. По названию. Например,
require('fs')
. Таким образом мы подключаем одну из библиотек, зашитых в ядро и описанных в документации.2. По относительному к текущему файлу пути. Например,
require('./classes/foo')
или require('../lib/mootools.js')
3. По абсолютному пути. Напримерр,
require('/home/spirit/lib/mootools')
Второй и третий путь, по сути, являются ссылкой на javascript файл, но без .js в конце (обязательно, иначе есть шанс поймать ошибку). Я рекомендую максимально оградить себя от использования относительных путей, так как они работают непоследовательно, зависят от окружения, да и в каждом файле имеют разный корень.
// __dirname - встроенная переменная, которая указывает // на директорию, в которой находится текущий файл var libPath = __dirname + '/../../lib'; // или так: /home/spirit/lib // на самом деле её можно привести в божеский вид с помощью // fs.realpathSync(libPath), но сейчас нет необходимости // Данный метод подключит MooTools ко всем файлам, // которые мы будем использовать далее require(libPath + '/MooTools').apply(GLOBAL); // Вот так будет стартовать наш фреймворк var spirit = require(libPath + '/spirit/spirit') .createSpirit(__dirname, libPath); // мне больше нравится задавать адрес-порт таким образом // но можно и так, как в require('http').listen spirit.listen("127.0.0.1:8124");
Пишем главный класс
Все, что мы хотим передать сделать возможным к экспорту — мы должны добавить в объект exports. То есть, если мы хотим делать
var foo = new (require('./bar').bar);
, в файле bar.js
мы должны сделать так: exports.bar = new Class({ /* ... */ });
. Во-первых я предпочитаю объявлять такие переменные не через точку, а в квадратных скобках, тогда в редакторах оно подсвечивается как строка (которых обычно меньше всего), что дополнительно выделяет название класса с которым мы работаем. Во-вторых, сначала такой подход мне не понравился и я даже счел его неудобным (в примере выше видна необходимость при require
повторять bar
дважды), но в итоге мы повернем его так, чтобы он сыграл нам на руку — будет удобно и красиво. Итак, функция создания главного класса фреймворка:exports['createSpirit'] = function (projectPath, libPath) { return new Spirit(projectPath, libPath); };
В самом классе мы реализуем следующие идеи:
1. Все классы лежат в
app/classes
и lib/spirit/classes
2. При загрузке какого-нибудь класса по имени мы сначала проверяем директорию приложения, потом — фреймворка (если не сказано иначе). Таким образом можно будет перегрузить классы фреймворка при необходимости (данная деталь будет описана позже)
3. Загруженные классы — кешируются, что позволит избежать лишнего обращения к ФС (или в node.js оно и так кешируется?)
4. Класс будет отправной точкой для всех остальных действий
Код класса я выложу на пастбин, а тут опишу только интерфейс
pastebin.com/0b14MEbe
var Spirit = new Class({ Binds: ['hit', 'load'], initialize : function (String projectPath, String libPath) { }, load : function (String className, Boolean fromSpirit) { // с помощью load мы будем грузить классы, например: // spirit.load('Controllers.User'); // сначала грузя их из приложения, а потом, если не нашли - из фреймворк // флаг fromSpirit позволит загружать напрямую // класс фреймворка, игнорируя классы приложения }, loadLib : function (libName) { // Неоходимо для изящной загрузки библиотек, например: // spirit.loadLib('mysql-libmysqlclient') // библиотека должна находится внутри libPath // в директории с таким же названием как и основной файл }, // Каждый запрос будет вызван этот метод hit : function (req, res) { }, listen : function (port, url) { // Мы можем принять аргументы // как .listen(8124, '127.0.0.1') и // как .listen('127.0.0.1:8124') } }); var load = function (path, className) { // some code is here // Из экспорта мы вызываем функцию с именем класса // и передаем объект Spirit. Зачем и как с ним работать? // смотрите ниже! return data[className](this); };
Так подход приводит к следующему стилю создания классов.
exports['Name.Space.ClassName'] = function (spirit) { return new Class({ method : function (msg) { this.response.writeHead(200, {'Content-Type': 'text/plain'}); this.response.end(msg); } }); };
Пока непонятно, зачем это нужно?
exports['Helper.AnotherClass'] = function (spirit) { var mysql = spirit.loadLib('mysql-libmysqlclient'); return new Class({ Extends : spirit.load('Name.Space.ClassName'), start : function (msg) { this.method('Started'); }, query : function () { // using mysql } }); };
А как бы вы инклудили
Name.Space.ClassName
? require('../Name/Space/ClassName');
? А если класс в другое место перенести — переписывали бы все пути? А библиотеку подгружали бы вписывая полный путь?Давайте глянем другой пример. Допустим, у нашего фреймворка есть класс Router, который обрабатывает каждый хит:
exports['Router'] = function (spirit) { return new Class({ // .. hit : function (request, response) { // some code }, // .. }); };
Мы хотим ввести логирование хитов. Объявляем в директории нашего приложения класс Router:
exports['Router'] = function (spirit) { var Logger = spirit.load('Logger'); var logger = new Logger; return new Class({ Extends : spirit.load('Router', true), hit : function (request, response) { logger.log('hit!'); this.parent(request, response); }, // .. }); };
Таким образом мы может расширять классы, меняя поведение фреймворка и не лезть в исходный код.
Создаем роутер
Роутер будет парсить запросы к нему и отдавать на выполнение нужному контроллеру.
Роутинг будет происходить по следующей схеме:
1. Сначала с помощью регекспов проверяется совпадение с одним из адресов вручную добавленных роутов. Это позволит ввести особые урлы, которые не попадают под принцип работы роутера по-умолчанию
2. Если роут не найден в пункте 2 адрес разбивается по слешам и среди контроллеров ищется ближайший подходящий. При адресе url/AA/BB/CC/DD/EE сначала ищется контроллер AA.BB.CC.DD.EE, потом AA.BB.CC.DD и так далее, пока не найдется нужный. Если такого контроллера нету — подставляется контроллер Index. Если нету контроллера AA.BB.CC.DD, но есть AA.BB.CC.DD.Index, то будет выбран именно он. Потом выбирается метод и все остальное передается в качестве аргументов.
Все классы контроллеров будут грузится при инициализации приложения, что позволит избежать лишних просчетов при каждом запросе.
Примерно так будет выглядеть каталог нашего приложения:
/home/spirit/example-app/
├ engine/
│ ├ classes/
│ │ ├ controllers/
│ │ │ ├ Admin/
│ │ │ │ ├ Articles.js
│ │ │ │ └ Index.js
│ │ │ ├ Man/
│ │ │ │ ├ Index.js
│ │ │ │ └ Route.js
│ │ │ ├ Index.js
│ │ │ └ Users.js
│ │ └ Controller.js
│ └ init.js
├ logs/
└ public/
В наш init-файл приложения добавим следующий код, который продемонстрирует подход к ручным роутам. :A, :D, :H, :W соответсвуют следующим шаблонам: [a-z], [0-9], [0-9a-f], [0-9a-z] соответственно. < и > отвечают за то, чтобы это выражение стояло в начале и в конце адреса соответственно. Таким образом, выражение, например
</test-:A>
не совпадет с урлом "/test-abc123
", в то время, как выражение /test-:A
с этим урлом совпадает. Все шаблоны складываются в массив и передаются аргументом при вызове метода контроллера. Если же есть argsMap, то передается Хеш. Например, при адресе "/articles-15/page-3
" первым аргументом будет массив [15, 3]
, но если передать argsMap : ['id', 'page']
в метод передастся хеш {id:15, page:3}
.spirit.createRouter() .addRoutes( { route : "</article-:D/page-:D" , contr : 'Man.Route:articleWithPage' , argsMap : ['id', 'page'] }, { route : "</article-:D" , contr : 'Man.Route:article' , argsMap : ['id'] }, { route : "</~:W>" , contr : 'Man.Route:user' }, { route : "</hash-:H>" , contr : 'Man.Route:hash' } );
А вот контроллер, который работает с этим кодом (на схеме вверху он выделен жирным):
exports['Controllers.Man.Route'] = function (spirit) { return new Class({ Extends : spirit.load('Controller'), indexAction : function () { this.exit('(Man.Route) index action'); }, testAction : function () { this.exit('(Man.Route) test action'); }, articleWithPageAction : function (args) { this.exit('(Man.Route) article #' + args.id + ', page #' + args.page); }, articleAction : function (args) { this.exit('(Man.Route) article #' + args.id); }, hashAction : function (args) { this.exit('(Man.Route) hash: ' + args[0]); }, userAction : function (args) { this.exit('(Man.Route) user: ' + args[0]); } }); };
И родительский контроллер, который будем использовать для того, чтобы всем детям задать метод
exit
:exports['Controller'] = function (spirit) { return new Class({ exit : function (msg) { this.response.writeHead(200, {'Content-Type': 'text/plain'}); this.response.end(msg); } }); };
Для начала необходимо расширить класс Spirit, переложим анализ запросов полностью на плечи Роутера:
createRouter : function () { var Router = this.load('Router'); var router = new Router(); router.spirit = this; this.router = router; router.init(); return router; }, hit : function (req, res) { this.router.hit(req, res); },
Сам роутер тоже не будет особо нагроможденным и основную работу отдаст своему заместителю:
exports['Router'] = function (spirit) { var RouterHelper = spirit.load('Router.Helper'); return new Class({ init : function () { var path = this.spirit.requirePath + 'Controllers'; this.routerHelper = new RouterHelper(this); this.routerHelper.requireAll(path); }, hit : function (request, response) { var contrData = this.routerHelper.route(request); var contr = contrData.contr; contr.spirit = this.spirit; contr.request = request; contr.response = response; if (typeof contr.before == 'function') contr.before(); contr[contrData.method](contrData.args); if (typeof contr.after == 'function') contr.after(); }, addRoutes : function () { var rh = this.routerHelper; rh.addRoutes.apply(rh, arguments); } }); };
Заместитель тоже имеет двух подчиненных — того, кто отвечает за вручную добаленные роуты(RouterRegexp) и кто роутит способом по-умолчанию (RouterPlain). Обратите внимание на метод requireAll — в нем в синхронном стиле рекурсивно обходится директория контроллеров и подключаются все классы. В данном случае асинхронность не обязательна, так как этот метод вызывается только при инициализации проекта, но в реальном коде такие вещи желательно писать в асинхронном стиле — время обращения с файловой системой не будет тормозить процесс выполнения кода. Node.js мне нравится тем, что в отличии от некоторых других языков — все имена методов понятные, красивыe, короткие и соблюдают единый стиль.
var fs = require('fs'); exports['Router.Helper'] = function (spirit) { var RouterPlain = spirit.load('Router.Plain'); var RouterRegexp = spirit.load('Router.Regexp'); return new Class({ initialize : function (router) { this.router = router; this.plain = new RouterPlain(this); this.regexp = new RouterRegexp(this); }, route : function (request) { var url = request.url; return this.regexp.route(url) || this.plain.route(url); }, requireAll : function (path) { var files = fs.readdirSync(path); for (var i = 0; i < files.length; i++) { var file = path + '/' + files[i]; var stat = fs.statSync(file); if (stat.isFile()) { this.addController(file); } else if (stat.isDirectory()) { this.requireAll(file); } } this.checkAllIndexActions(); }, // тут просто переадресовываем вызов на внутренний regexp хелпер addRoutes : function (routes) {}, // убираем ".js" с конца с помощью простой регулярки removeExt : function (file) {}, // проверяем на обязательное наличие метода indexAction checkAllIndexActions : function () {}, // добавляет класс контроллера в кеш роутерХелпера addController : function (file) {}, // возращает объект контроллера или false, если нету createController : function (name) {} }); };
В первую очередь необходимо разобрать регулярки, которые переданны с помощью
addRoutes
exports['Router.Regexp'] = function (spirit) { return new Class({ initialize : function (routerHelper) { this.routerHelper = routerHelper; }, route : function (url) { // просто проходимся по всем регуляркам и сравниваем их с текущим адресом for (var i = 0; i < this.routes.length; i++) { var route = this.routes[i]; // так как мы используем один и тот же объект регулярки // каждый запрос нам необходимо обнулить lastIndex, иначе // каждый второй пользователь будет видеть не тот адрес, // который необходим route.regexp.lastIndex = 0; var result = route.regexp.exec(url); if (result) { return { contr : this.routerHelper .createController(route.contr.name), method : route.contr.method, args : this.regexpRouteArgs(result, route.argsMap) }; } } return false; }, routes : [], addRoute : function (route, controller, argsMap) { }, addRoutes : function (hash) { }, // разбивает строку из названия контроллера, переданного через addRoutes // по двоеточию и первую часть использует как название контроллера, // а вторую(если есть)) - как название метода regexpContr : function (string) { var parts = string.split(':'); var method = parts.length > 0 ? parts[1] + 'Action' : 'indexAction'; var contr = 'Controllers.' + parts[0]; // ... }, // в этом методе мы собираем и компилируем регулярки // компиляция должна осуществлятся при инициализации // проекта, чтобы лишний раз не выполнять пустые действия regexpRoute : function (route) { var re = new RegExp(); re.compile(this.prepareRegexp(route), 'ig'); return re; }, replaces : { A : '([a-z]+)', D : '([0-9]+)', H : '([0-9a-f]+)', W : '([0-9a-z]+)', }, prepareRegexp : function (route) { return route .escapeRegExp() .replace(/>$/, '$') .replace(/^</, '^') .replace(/:([ADHW])/g, function ($0, $1) { return this.replaces[$1]; }.bind(this)); }, // превращает массив, полученный из regexp.exec в // обычный массив без input и lastIndex или // в объект, если есть argsMap regexpRouteArgs : function (result, argsMap) { }, }); };
Когда не сработал роутер по регулярным выражениям — используем роутер по-дефолту:
var url = require('url'); exports['Router.Plain'] = function (spirit) { return new Class({ initialize : function (routerHelper) { this.routerHelper = routerHelper; }, route : function (url) { var parts = this.getControllerName(url); var controller = this.routerHelper.createController(parts.name); var method = 'indexAction'; if (parts.args.length) { var action = parts.args[0].lcfirst(); // если остались аргументы - из первого стараемся выбрать метод // если такой метод есть - используем его, иначе - используем indexAction if (typeof controller[action + 'Action'] == 'function') { method = action + 'Action'; parts.args.shift(); } } return { contr : controller, method : method, args : parts.args }; }, getControllerName : function (url) { var controllers = this.routerHelper.controllers; // Разбиваем pathname на части в массив по слешу var path = this.splitUrl(url); var name, args = []; do { if (!path.length) { // если не осталось частей - name = 'Controllers.Index'; break; } // Сначала стараемся найти индексный контроллер в такой директории name = 'Controllers.' + path.join('.') + '.Index'; if (controllers[name]) break; // потом - просто контроллер с таким названием name = 'Controllers.' + path.join('.'); if (controllers[name]) break; // если не получилось - откидываем последнюю часть args.unshift(path.pop()); } while (true); return { name : name, args : args }; }, splitUrl : function (urlForSplit) { return url .parse(urlForSplit, true) .pathname.split('/') .filter(function (item) { return !!item; }) .map(function (item) { return item.ucfirst(); }); }, }); };
Расширение классов
UPD: В комментах меня попросили показать расширенный роутер, когда в урле надо указать ссылки на два разных файла, например при сравнении ревизий в репозитарии. Но ссылки должны быть не по идентификатору, а по пути, например "
shock/spirit/init.js/f81e45
". Я предлагаю поспользоватся таким шаблоном ссылки:http://example.com/compare/(shock/spirit/init.js/f81e45)/(tenshi/spirit/src/init.js/d5d218)
, в котором каждый файл указан в скобках. Но средства фреймворка не позволяют это сделать. Не беда. В нашем проекте(не трогая фреймворк) создаем класс Router.Regexp:exports['Router.Regexp'] = function (spirit) { return new Class({ Extends : spirit.load('Router.Regexp', true), prepareRegexp : function (route) { return this.parent(route) .replace(/:P/g, '([0-9a-z._\\/-]+)'); } }); };
Мы ввели новый модификатор — ":P". Наверное, корректнее было бы просто расширить объект Router.Regexp.replaces, но я хотел показать возможности перегрузки методов. Отлично, теперь добавляем новый роут в init.js:
spirit.createRouter() .addRoutes( // ... { route : "</compare/(:P)/(:P)>" , contr : 'Man.Route:compare' } );
И добавляем метод в Man.Route:
compareAction : function (args) { this.exit('Compare "' + args[0] + '" and "' + args[1] + '"'); }
Переходим по ссылке
http://example.com/compare/(shock/spirit/init.js/f81e45)/(tenshi/spirit/src/init.js/d5d218)
и получаем ответ:Compare "shock/spirit/init.js/f81e45" and "tenshi/spirit/src/init.js/d5d218"
Заключение
Итак, мы создали наш проект, заставили сервер отображать его в веб, научились инклудить классы и, разобрав адрес, отсылать выполнение действия в необходимый контроллер. Следующими статьями мы подключим View в виде какого-нибудь шаблонизатора, и Model, в котором будет хранится информация о наших объектах. Через пару статтей попробуем написать блог на нашем фреймворке. Интересно?