Как стать автором
Обновить

«Spirit»: Node.js MVC Framework

Время на прочтение13 мин
Количество просмотров8.1K

Привет, ребята! С этого момента я хочу начать цикл статтей с подробностями по созданию сообственного 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, в котором будет хранится информация о наших объектах. Через пару статтей попробуем написать блог на нашем фреймворке. Интересно?
Теги:
Хабы:
+53
Комментарии87

Публикации

Истории

Работа

Ближайшие события