node.js сокращатель ссылок


    Привет, Хабр! В этой статье я пошагово рассмотрю создание простого веб-приложения — сокращателя ссылок на node.js, используя mysql-libmysqlclient, MooTools на сервере и jQuery на клиенте. Статья предполагает, что читатель уже прошёл упражнение «Hello world» и разобрался в самых основах node.js.

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

    Серверный MooTools


    Я не хочу холиварить на тему нужно-ненужно, я предпочёл использовать, вы можете решить иначе.
    Сходу подключить MooTools не удалось — нельзя просто взять, скачать серверный файл и сделать require('./MooTools').
    Мне помог твит MooTools от 12 июля и ссылка davidwalsh.name/mootools-nodejs. В итоге, библиотека подключилась так:
    require('./Lib/MooTools').apply(GLOBAL);
    


    MySQL


    Для данного приложения больше подходит какая-нибудь NoSQL база данных, но моей целью было именно научится работать с MySQL в node.js.
    Я достал из закладок топик про Node-mysql-libmysqlclient, взял всю необходимую инфу и по мануалам без проблем собрал клиент.
    Сам скрипт я закинул в папку Lib в проекте, потому он легко инициируется так:
    var conn = require('./Lib/mysql/mysql-libmysqlclient').createConnectionSync();
    conn.connectSync('localhost', 'NAME', 'PASS', 'nodejs');
    


    Расширяем ServerResponse


    Для того, чтобы было удобно задавать хедеры в приложении и редиректить я решил расширить прототип ServerResponse(считаю, что такой подход соответствует идеологии JavaScript) с помощью метода MooTools implements:
    var http  = require('http');
    http.ServerResponse.implement({
    	// code  - какой код вернула страница. Это может быть, например 200 (Ok) или 404 (Not Found)
    	// plain - отдаем просто текст если true, иначе - html
    	header : function (code, plain) {
    		this.writeHead(code, {
    			'Content-Type': plain ? 'text/plain' : 'text/html'
    		});
    	},
    	// response.redirect('http://example.com') перенесет нас на соответствующий адрес
    	redirect : function (url, status) {
    		this.writeHead(status || 302, {
    			'Content-Type' : 'text/plain',
    			'Location'     : url
    		});
    		this.write('Redirecting to ' + url);
    		this.end();
    	}
    });
    


    Каркас приложения


    http.createServer(function (req, res) {
    	// code will be here
    }).listen(8124, "127.0.0.1");
    console.log('Server running at http://127.0.0.1:8124/');
    


    Ссылки, файл Link.js


    Итак, ссылка у нас имеет три свойства — id в базе данных, url, на который она ведет и code, который показывается в нашем сокращателе.
    Мы не будем писать отдельно code в базу, а будем получать его на базе id, переводя его в 36-ритчную систему счисления ([0-9a-z]).
    Link.getCode  = function (id) {
    	return id ? id.toString(36) : null;
    };
    
    Link.fromCode = function (code) {
    	return code ? parseInt(code, 36) : null;
    };
    


    На базе этого построим сам класс-ссылки, который разместим перед статическими методами:
    Link = new Class({
    	initialize : function (obj) {
    		// если не получилось установить айди - пытаемся получить его из кода
    		this.setId(obj.id).id || this.setCode(obj.code);
    		this.setUrl(obj.url);
    	},
    	setId : function (id) {
    		// id должен быть исключительно int
    		this.id = (isNaN(id) || id <= 0) ? null : parseInt(id);
    		return this;
    	},
    	getId : function () {
    		return this.id;
    	},
    	setUrl : function (url) {
    		this.url = url || null;
    		return this;
    	},
    	getUrl : function () {
    		return this.url;
    	},
    	setCode : function (code) {
    		this.id = Link.fromCode(code);
    		return this;
    	},
    	getCode : function () {
    		return Link.getCode(this.id);
    	}
    });
    


    В базе создадим простую таблицу и напишем модель для получения и вставки ссылки:
    CREATE TABLE `shortLinks` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `url` varchar(512) NOT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB  DEFAULT CHARSET=utf8;
    


    Model = new Class({
    	initialize : function (conn) {
    		// ссылка на соединение с базой данных
    		this.conn = conn;
    	},
    	create : function (args) {
    		// так будет легче создавать ссылки, в следующем файле увидим
    		return new Link(args);
    	},
    	get : function (link, fn) {
    		// нам не нужны неверные
    		if (!link.getId()) throw 'EmptyId';
    
    		// простой запрос. мы знаем, что link.id может быть только int
    		var q = 'SELECT * FROM `shortLinks` WHERE `id` = ' + link.getId();
    
    		// запросы необходимо делать асинхронно. такая идеология языка.
    		this.conn.query(q, function (err, res) {
    			if (err) throw err;
    
    			res.fetchAll(function (err, rows) {
    				if (err) throw err;
    
    				// по завершению мы вызовем ф-цию, переданную в метод
    				fn(rows.length ? link.setUrl(rows[0].url) : null);
    
    				res.freeSync();
    			});
    		});
    	},
    	put : function (link, fn) {
    		// url - небезопасен, потому !обязательно! необходимо его экранировать
    		var q = 'INSERT INTO `shortLinks` (`id`, `url`) ' +
    			'VALUES (NULL , "' + this.conn.escapeSync(link.getUrl()) + '");'
    
    		this.conn.query(q, function (err, res) {
    			if (err) throw err;
    
    			// указываем новый айди и передаем ссылку
    			fn(link.setId(
    				// таким способом можно получить айди вставки
    				this.conn.lastInsertIdSync()
    			));
    		}.bind(this));
    	}
    });
    
    // Эти классы будут экспортироваться
    exports.Link  = Link;
    exports.Model = Model;
    


    В инициализацию можно добавить следующую строку:
    var linkModel = new (require('./Link').Model)(conn);
    

    Обратите внимание, скобки вокруг require('./Link').Model — обязательны, иначе оно постарается создать объект require.

    Рендерер, файл Renderer.js



    // не забываем во всех файлах подключать Мутулз
    require('./Lib/MooTools').apply(GLOBAL);
    
    // нам понадобится обработка ссылок
    var url = require('url');
    // и работа с файловой системой
    var fs  = require('fs');
    
    exports.Renderer = new Class({
    	initialize : function (linkModel) {
    		this.link = linkModel;
    	}
    });
    


    Renderer, как и Link.Model будет создаваться только один раз при инициализации приложения и каждый запрос использоваться один и тот же, за счет чего мы не теряем драгоценные миллисекунды.
    Основой будет метод run:

    run : function (req, res) {
    	var path = url.parse(req.url, true);
    	if (path.query && 'add' in path.query) {
    		var addUrl = path.query.add;
    		// пользователь может передать ссылку без протокола, например, "example.com"
    		// Чтобы она не вела нас на внутреннюю страницу, необходимо добавить default-протокол
    		if (!url.parse(addUrl).protocol) {
    			addUrl = 'http://' + addUrl;
    		}
    		// добавляем новую ссылку
    		this.add(res, addUrl);
    	// если это сокращенная ссылка (формат url/!abc12 )
    	} else if (path.pathname.test(/^\/![0-9a-z]+$/)) {
    		// отрезаем первых два служебных символа (/!) и ссылаем пользователя
    		this.send(res, path.pathname.substr(2));
    	} else {
    		// во всех остальных случаях рендерим главную страницу
    		this.index(res);
    	}
    },
    


    Пока не забыли — немного отредактируем ./init.js
    var linkModel = new (require('./Link').Model)(conn);
    var renderer  = new (require('./Renderer').Renderer)(linkModel);
    
    http.createServer(function (req, res) {
    	renderer.run(req, res);
    }).listen(8124, "127.0.0.1");
    


    Т.к. ссылки у нас будут добавлятся аджаксом, все, что нам надо — это записать ссылку в базу и вернуть её код:
    add : function (res, url) {
    	res.header(200);
    	this.link.put(
    		this.link.create({ url : url }),
    		function (link) {
    			res.end(link.getCode());
    		}
    	);
    },
    


    Если пользователь хочет перейти по сокращенной ссылке — шлем его куда надо, или сообщаем, что такой ссылки нету
    send : function (res, code) {
    	this.link.get(
    		this.link.create({ code : code }),
    		function (link) {
    			if (link) {
    				res.redirect(link.getUrl());
    			} else {
    				res.header(404, true);
    				res.end('There is not such url');
    			}
    		}
    	);
    },
    


    Для главной страницы — просто выводим файл index.html. Обратите внимание, я рекомендую обязательно указывать путь с __dirname вначале, т.к. иначе вы можете наткнуться на неприятности
    index : function (res) {
    	fs.readFile(__dirname + '/index.html', function (err, data) {
    		if (err) throw err;
    		res.header(200);
    		res.end(data);
    	})
    }
    


    Главная страница


    На главной странице мы поступим просто — подключим с репов гугл джиквери, добавим поле ввода и когда пользователь захочет сократить ссылку — мы аджаксом получив код новой ссылки, красиво выведем её под полем ввода.
    <!DOCTYPE html>
    <html>
    	<head>
    		<meta charset="UTF-8">
    		<title>Сокращатель ссылок на node.js</title>
    		<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
    		<script>
    

    $(function () {
    	$('input[type=submit]').click(function() {
    		var input = $('input[type=text]');
    		var url   = input.val();
    		input.val('');
    		url && $.ajax({
    			url : './',
    			data: ({ add : url }),
    			success : function (data) {
    var result = location.protocol + '//' + location.host + '/!' + data;
    $('#url')
    	.prepend(
    		$('<dd>').append(
    			$('<a>')
    				.text(result)
    				.attr('href', result)
    		)
    		.hide()
    		.fadeIn()
    	)
    	.prepend(
    		$('<dt>')
    			.text(url)
    			.hide()
    			.fadeIn()
    	);
    			}
    		});
    	})
    });
    

    		</script>
    	</head>
    	<body>
    		<div id="form">
    			<input type="text"   />
    			<input type="submit" />
    		</div>
    		<dl id="url"></dl>
    	</body>
    </html>
    


    Все.

    Результат


    Сразу предупрежу — сохранность ваших ссылок не гарантирую, сервис исключительно демонстративный.

    Весь код в одном месте на pastebin
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 60

      +2
      Вот вы обмолвились, что для этих целей подошел бы nosql. Но как же проблема с автоинкрементами? Ведь тут все на красивом id завязано. Хотел в последнем проекте использовать монго, но в связи с невозможностью грамотно генерировать иды, необходимые в приложении, пришлось отказаться
        +4
        В Redis можно организовать.
          +3
          Имел дело с монго и кочем
          В коче можно сделать через view+reduce. Собственно и сделал, но как только дошло до тестов дело… Оказалось, что он в 10 раз медленнее того же мускула. А в теории все эти статичные запросы выглядели так сладко
          В монго такой финт уже не сделаешь
          +2
          Есть два варианта. Первый — не брать за основу айди. Использовать, например, хеш от микровремени или еще чего-то

          Второй вариант — в той же Монго хранить поле «lastId», которое вручную инкрементировать

          Но на самом деле я не особый сторонник моды NoSQL и вполне радуюсь старенькой MySQL
            +2
            1 — сделал парсер на ноде, все валит в один момент.
            2 — тот же монго достаточно скоро потребует шариться, а безопасность атомарных операций там никто не гарантирует
            В голове вертятся только гибриды
              0
              Автоинкремент в монго можно сделать так: links.freecr.ru/!bvc
              Но можно еще подумать над сокращением ObjectId, которое выглядит так:

              47cc67093475061e3d95369d

              Но как его сокращать, оставляя уникальным — я хз. Мне в проекте это требовалось и я не допёр, но вместо этого я использовал собственную реализацию автоинкремента в монго.
              +2
              Используем Redis и Keyspace в обоих случаях атомарный инкремент присутствует.
                +1
                Redis выглядит заметно лучше, но все-равно советы сводятся к применению уникального идентификатора, то есть того же самого guid`a.
                0
                Можно, смотрите: links.freecr.ru/!bvc
                0
                Спасибо, очень полезно. Смотрю на такие вещи после питон+твистед очень положительно.
                  +2
                  свои коллбэки обьявляя с err, можно в случае ошибок вложенного вызова просто вызывать callback с полученным err.

                  Использовать throw некорректно в асинхронной модели.
                  +4
                  А в такого рода сервисах нужно же проверять, если ли уже такой URL в базе? А то как-то…

                  habrahabr.ru
                  links.freecr.ru/!et
                  habrahabr.ru
                  links.freecr.ru/!er
                  habrahabr.ru
                  links.freecr.ru/!eq
                  habrahabr.ru
                  links.freecr.ru/!eo
                    +1
                    Ну, тут зависит от политики сервиса. Если проверять наличие, то это поиск в хранилище, что увеличивает время создания ссылки и уменьшает размер хранилища. А если не проверять, то ссылка создается быстро, но увеличивается хранилище ссылок.

                    Популярные укорачиватели такой проверки не делают.
                      0
                      bit.ly, сабмитил 5 раз, разультат habrahabr.ru/new/ -> bit.ly/xEvSA (на j.mp, соответственно, тоже)
                        +1
                        пардрон, браузер тупит:
                        goo.gl, сабмитил 5 раз, результат habrahabr.ru/new/ -> goo.gl/QcLw
                        tinyurl.com, сабмитил 5 раз, результат habrahabr.ru/new/ -> tinyurl.com/4sbk7u
                        byst.ro, сабмитил 5 раз, результат habrahabr.ru/new/ -> byst.ro/3bwh

                        Уверен, что если еще проверить — большинство будет отдавать один урл… его проще 1 раз закешировать, чем каждый раз писать в базу.
                          0
                          я думал об этом, но. часто бывает, что человек хочет собрать статистику по переходам на урл. например, я в топике выкладываю ссылку и хочу узнать, сколько раз по ней перешли. но, возможно, имеет смысл брать уже готовые, да
                            +1
                            Agree — cl.ly на каждый писк создает новую ссылку и персональную статистику по каждой.
                            0
                            а у меня goo.gl создаёт каждый раз новую ссылку
                            +2
                            Да, действительно :-/

                            Я задавался этим вопросом несколько месяцев назад и политика создания урла была другая. Клянусь (с) =)
                        +2
                        Немного не понял, а проверки на валидность url не предусмотрено?
                          +6
                          Прикольно получается когда пустую форму отпрвляешь.
                            +2
                            Я вот недавно тоже сел за ковыряние ноды, и, не поверите, тоже в голову пришло сделать сокращалку урлов :)
                            Вот мой вариант: sudn.tk. Исходники покажу по запросу, выкладывать их стыдно.
                            Там проверки тоже примитивные… yandex.ru и yandex.ru/ посчитает за разные урлы.

                            Да, делал на ExpressJS, в качестве хранилища редис, шаблонизатор ejs.
                              0
                              Урлы лучше парсить с помощью соответствующего метода и сравнивать полученные объекты (либо их хеши).
                                +1
                                вы предлагаете перебирать всю базу, парсить её этим методом и сравнивать потом объекты?
                                  +1
                                  Нет, сохранять хеш в базе. По нему же можно генерировать короткий ID.
                                    +1
                                    ну, это вариант, конечно.
                                    0
                                    Зачем? Храните сразу md5-хеши объектов.
                              • UFO just landed and posted this here
                                  +2
                                  Думаю что если сервис будет популярен, то будут частые обращения и любой ФС тут станет нехорошо.
                                  Представьте себе 1к обращений в единицу времени. Это надо прочитать 1000 файлов, получается. Параллельно.
                                  А параллельное чтение большого количества файлов — это либо дорого, либо практически нереально.
                                  Да и наверное память и цпц нынче дешевле дискового пространства.

                                  Я конечно могу ошибаться, ибо не сильно много понимаю в ФС и файловых операциях :)
                                  А кстати да — как можно читать кучу файлов параллельно? Ну то есть тут, как я понимаю всё упирается в скорость чтения головки с хдд и в «раскиданность» файлов по ФС?
                                    0
                                    При достаточном количестве памяти ОС Linux будет держать кеш файлов в ОП и обращения будут сверх быстрыми. Есть только один подводный камень — fileatime, то есть последнее обращение к файлу. Но при монтировании файловых систем можно поставить noatime и избавиться от этого.
                                    Как дело обстоит в Windows и для NTFS, не знаю.
                                      0
                                      Спасибо за объяснения :)
                                      • UFO just landed and posted this here
                                          0
                                          Дело в опыте. Первые лет 5 всегда делаешь всё через MySQL. Уже потом начинаешь задумываться.
                                            0
                                            sqlite?
                                            +1
                                            ещё два подводных камня:
                                            1) ограничение на суммарное количество файлов в файловой системе (вернее, inodes);
                                            2) резкое снижение производительности при большом числе файлов в одном каталоге (но это легко решаемо).
                                            +1
                                            а зачем? по-моему бред и вот почему: сегодня я использую фс, завтра, когда потребности проекта возрастут, буду писать велосипед того, как бы это все красиво заоптимизировать, а послезавтра, когда захочется переехать в облако пойму, что где-то в проектировании системы был прокол. почему бы сразу не использовать бд, которая как раз для этого и создана? не обязательно реляционную, для такого проекта сгодится простенькая key-value бд вроде редис. а чтобы начать работать с ней, нужно меньше времени, чем потом бороться с граблями файловой системы. скажите я не прав?
                                              0
                                              смысл? если я захочу расширить приложение — я смогу это с легкостью сделать. работа с базой тоже будет безумно быстрой в этом приложении.
                                              тем более, я уже сказал, что можно было бы использовать НоуСКЛ, но целью было именно научится работать с MySQL
                                              +1
                                              Забавно использовать для серверной части MooTools, а для клиентской — jQuery.
                                              Хотя классы там удобные — это факт, а как с этим в jQuery, я не знаю. И как у jq с node.js
                                                +1
                                                jQuery прекрасно работает в приложениях node.js с использованием jsdom.
                                                  0
                                                  для операций с DOM jquery — лучший. Но на сервере он — не очень. Имхо
                                                    0
                                                    Иногда полезен, при операциях с деревом, и для идентичности кода.
                                                      0
                                                      А по-моему, и mootools для этого нисколько не хуже. Странно, что вы решили серверную часть писать с использованием mootools, а на клиенте — jquery.
                                                        +1
                                                        я не могу отдать предпочтение определенному фреймворку. наверное, ДОМ мне нравится больше в JQuery. Хотя многие вещи мне больше нравятся в MooTools. Они разные на самом деле
                                                          0
                                                          jQuery больше для дизайнеров, как мне кажется,
                                                          в то время как mootools больше для разработчиков
                                                  +1
                                                  Бага: если отправить пустое поле, то вот такой ответ я получаю:
                                                  habreffect.ru/files/3dd/fd035b90a/scr19.png
                                                    0
                                                    Это сократили:)
                                                      0
                                                      пофиксил
                                                      +3
                                                      Спасибо за статью, красивый код и доступное описание.

                                                      Небольшое дополнения: String.sqlEscape можно было не реализовывать, ведь есть conn.escapeSync(). Потери производительности будут минимальны, но зато точно не забудете заэкранировать что-нибудь, а вы явно забыли \n, \r и т.д. Впрочем, для INSERT уже можно использовать prepared statements.
                                                        +4
                                                        поприветствуем автора mysql-libmysqlclient)
                                                        спасибо, conn.escapeSync() — это то, что нужно.
                                                        ждем подробных мануалов, что и как использовать)
                                                          +2
                                                          Да, похоже прежде всего нужно допилить Dox для генерации постраничной документации, а то в едином списке в api.html трудно ориентироваться.
                                                          Поддерживать документацию вручную для одного разработчика довольно хлопотное занятие. Но пример с INSERT и экранированием в срочном порядке добавлю в examples.js, моё упущение.
                                                        +1
                                                        Было сокращено более 15000 ссылок.
                                                          0
                                                          в хроме не работает
                                                            0
                                                              0
                                                              ок. Оказывается не работает нажатие на «enter» после того как вставил урл.
                                                            0
                                                            Сервис развиваетсья? прносит прибыль?
                                                              0
                                                              Сервис не развивается, прибыль не приносит и вообще не работает. В общей сложности было сокращенно около 20000 ссылок за первые дни. Нагрузка — близка к нулю, отработал прекрасно.
                                                                0
                                                                Почему дальше не разивали? не видели смысла или другими проектами заняты?
                                                                  0
                                                                  Не видел смысла, есть куча других проектов. Сокращалок — много, сомневаюсь, что смогу дать что-то особенное.
                                                                  Хотя, если кто-то станет успешным с кодом, описанным в статье — я буду рад.
                                                              0
                                                              if (err) throw err;

                                                              А что будет, если таки произойдет ошибка? Не упадет ли весь сервер?
                                                              Может правильнее дальше передавать:
                                                              if (err) fn(err);
                                                              ?

                                                              Only users with full accounts can post comments. Log in, please.