Скрипт на NodeJS для Backup-а данных: Начало

в 9:34, , рубрики: backup, javascript, node.js

Давно искал программу для сохранения своих проектов. При этом обязательным требованием было:

1. Сохранять в хранилище только изменившиеся файлы;
2. Упаковывать изменившиеся файлы;
3. Быть бесплатной.

Однако поиск ничего не дал. Вернее, поиск дал, но обычно один из пунктов отсутствовал. Поэтому я решил написать свою, а заодно «пощупать вживую» NodeJS. За 2 дня написал. И даже прикрутил к программе шифрование. Что не вызвало особых сложностей, так как модуль шифрования входит в стандартную поставку ноды. При этом сделал модули упаковки, шифрования и работы с хранилищем расширяемыми. Чтобы можно было просто добавлять новые возможности, расширяя функционал. В текущей версии сохранение работает только в файловую систему, без упаковки, но с шифрованием.

Посмотреть справку по использованию можно тут.

На этом можно бы и закончить – дело сделано. Но есть один минус – программа работает СИНХРОННО. И хотя работает достаточно шустро (если не сохранять гигабайты информации), но всё таки нода ориентирована на АСИНХРОННУЮ работу. Так что я посмотрел Скринкаст по Node.JS и решил сделать все по «правильному», c учетом особенностей ноды.

Раз уж решил писать «правильно», то начну с общей схемы работы:

image

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

1. Определяем список файлов для сохранения

Задача простая – на входе у нас есть директория, мы должны получить для каждого элемента следующие свойства: размер, контрольную сумму (будем использовать md5), дату изменения. Это нам нужно чтобы в дальнейшем определить изменившиеся элементы. Я пишу элементы, а не файлы не случайно. Будем определять эти значения и для директорий. Размер директории – это размер всех вложенных элементов. Контрольная сумма директории – это контрольная сумма всех контрольных сумм дочерних элементов. Соответственно, чтобы понять – были изменения в директории или нет – достаточно будет просто сравнить контрольные суммы этой директории в хранилище и на диске.

Синхронная реализация данного функционала
(function(){
	// Подключить нужные модули
	var fs = require('fs');
	var path = require('path');
	var crypto = require('crypto');
	// Получить список файлов
	function _get_attributes(itemName,basePath) {
		// Вывести для отладки
		//console.info(basePath,"ttt",itemName);
		// Размер элемента
		var retSize = 0;
		// Объект для определения контрольной суммы элемента
		var retHash = crypto.createHash('md5');
		// Дополнительная информация об элемента. Дата изменения для файла и дочерние элементы для директории
		var retInfo;
		// Полное имя файла
		var filepath = path.join(basePath,itemName);
		// Получим атрибуты файла
		var stat = fs.statSync(filepath);
		// Если это директория
		if( stat.isDirectory() )
		{
			// В дополнительной информации атрибуты дочерних элементов
			retInfo = {};
			// Получить список дочерних элементов
			var items = fs.readdirSync(filepath);
			var hashs = [];
			for(var i in items)
			{
				// Получить атрибуты дочернего элемента
				var attr = _get_attributes(items[i],filepath);
				// Увеличить размер текущего элемента
				retSize += attr[0];
				// Добавить в расчет контрольной суммы контрольную сумму дочернего элемента
				hashs.push(attr[1]);
				// Добавить в список дочерних элементов
				retInfo[ items[i] ] = attr;
			}
			// Расчитать контрольную сумму
			hashs.sort();
			for(var j in hashs)
			{
				retHash.update(hashs[j]);
			}
		}
		else
		{
			// Размер
			retSize = stat.size;
			// Расчитать контрольную сумму файла
				// Создадим буфер для чтения данных
				var buffer = new Buffer(64*1024);
				// Откроем файл только для чтения
				var fdr = fs.openSync(filepath, 'rs');
				while(true)
				{
					// Читаем порцию данных
					var bytesRead = fs.readSync(fdr, buffer,0,buffer.length);
					if(bytesRead>0)
					{
						// Обновляем контрольную сумму прочитанными данными
						retHash.update(buffer.slice(0,bytesRead));
					}
					else
					{
						// Закончим цикл чтения данных
						break;
					}
				}
				// Закрыть файл
				fs.closeSync(fdr);
			// Дата изменения файла
			retInfo = stat.mtime.toISOString();
		}
		// Вернуть массив атрибутов элемента
		// Контрольную сумму вы вернем в формате base64 и удалим из неё символ '='. Таким образом мы уменьшим размер этого значения
		return [retSize,retHash.digest('base64').replace(/=/g,''),retInfo];
	}
	// Вернем функцию для получения атрибутов
	module.exports = function(_basepath) {
		return _get_attributes('',_basepath);
	}	
})();

Асинхронная реализация данного функционала.

(function(){
	// Подключить нужные модули
	var fs = require('fs');
	var path = require('path');
	var crypto = require('crypto');
	// Очередь из файлов для расчета их контрольной суммы
	var dm5CalcStorage=[];
	// Количество активных заданий для расчета контрольной суммы файла
	var dm5Calc = 0;	
	// Максимальное количество активных заданий для расчета контрольных сумм файлов
	var dm5CalcMax = 8;
	// Функция расчета контрольной суммы файла
	function _get_md5_file() {
		// Создать поток для чтения файла
		if(dm5Calc<dm5CalcMax && dm5CalcStorage.length)
		{
			// Увеличить количество обрабатываемых файлов
			dm5Calc++;
			// Взять из очереди очередной файл
			var item = dm5CalcStorage.pop();
			// Создать поток для чтения файла
			var rs = fs.createReadStream(item[0]);
			// Вещаем обработчик на чтение данных из файлового потока
			//*
			rs.on('data', function (data) {
				item[1].update(data);
			});
			// Вещаем обработчик на окончание чтения данных из файлового потока
			rs.on('end', function () {
				// Уменьшить количество обрабатываемых файлов
				dm5Calc--;
				// Вызвать функцию обратного вызова
				item[2]();
				// Вызвать функцию для запуска обработки следующего файла
				_get_md5_file();
			});
			//*/
			
		}
	}
	// Получить атрибуты элемента
	function _get_attributes(_basepath,callback) {
		//
		
		// Получить атрибуты элемента
		function __get_attributes(basePath,itemName,callback) {
			//console.log("_get_attributes",basePath,itemName);
			//
			var retSize;
			// Создать объект для расчета контрольной суммы
			var retHash = crypto.createHash('md5');
			var retData;
			//
			function _retResult(err) {
				var rc = [retSize,retHash.digest('base64').replace(/=/g,''),retData];
				//console.log("return",rc);
				callback(err,rc);
			}
			// Сформировать полный путь
			var filepath = path.join(basePath,itemName);
			// Определить атрибуты элемента
			fs.stat(filepath, function(err,stat) {
				// Если ошибка, то выход
				if (err) return callback(err);
				// Если это директория
				if( stat.isDirectory() )
				{
					// то получить список дочерних элементов
					fs.readdir(filepath,function(err,childItems) {
						// Если ошибка, то выход
						if (err) return callback(err);
						//
						retSize = 0;
						//retHash = "";
						retData = {};
						//
						if(childItems.length==0)
						{
							_retResult(null);
						}
						// Перебрать все дочерние элементы
						var attrs=[];
						childItems.forEach( function(_itemName){
							// Получить список файлов
							__get_attributes(filepath,_itemName,function(err,attr){
								//console.log("result",_itemName,attr);
								// Если ошибка, то выход
								if (err) return callback(err);
								// Добавить атрибут дочернего элемента
								attrs.push([_itemName,attr]);
								// Если для всех дочерних элементов определили атрибуты
								if(attrs.length==childItems.length)
								{
									// Отсортировать файлы по имени
									attrs.sort(function(a,b){
										if(a[1][1]<b[1][1]) return -1;
										if(a[1][1]>b[1][1]) return 1;
										return 0;
									});
									//console.log("attrs",attrs);
									attrs.forEach(function(attrx){
										// Увеличить размер
										retSize += attrx[1][0];
										//
										//retHash += attrx[1][1]+".";
										retHash.update(attrx[1][1]);
										//
										retData[ attrx[0] ] = attrx[1];
									});
									_retResult(null);
								}
							});
						} );
					});
				}
				else
				{
					retSize = stat.size;
					//retHash = "md5("+itemName+")";
					retData = stat.mtime.toISOString();
					// Добавить файл в очередь для расчета контрольной суммы
					dm5CalcStorage.push([filepath,retHash,function () {
						// Вызвать функцию обратного вызова и вернуть результат
						_retResult(null);
					}]);
					// Функция расчета MD5 файла
					_get_md5_file();
				}
			});
		}	
		// Вызввать функция для начала работы
		__get_attributes(_basepath,'',callback);
	}
	// Вернем функцию для получения атрибутов
	module.exports = _get_attributes;
})();	

При замере скорости работы асинхронная реализация всегда выигрывает. Чтобы еще ускорить процесс работы, я сделал расчет контрольной суммы через перенаправление потоков с помощью функции pipe(). Расчет был на то, что тогда чтение файла и расчет будет проходить не в javascrip-е, а в ядре ноды. Однако этот шаг себя не оправдал. Почему-то вариант вида:

			// Создать объект для расчета контрольной суммы
			var retHash = crypto.createHash('md5');
			retHash.setEncoding('base64');			
			// Создать поток для чтения файла
			var rs = fs.createReadStream(item[0]);
			// Вещаем обработчик на окончание чтения данных из файлового потока
			rs.on('end', function() {
				// Закончить расчет контрольной суммы
				retHash.end();
				// Уменьшить количество обрабатываемых файлов
				dm5Calc--;
				// Вызвать функцию обратного вызова
				item[1]( retHash.read() );
				// Вызвать функцию для запуска обработки следующего файла
				_get_md5_file();
			});
			// Перенаправить данные из файлового потока в поток для подсчета контрольной суммы
			rs.pipe(retHash);

Всегда выигрывал у варианта:

			// Создать объект для расчета контрольной суммы
			var retHash = crypto.createHash('md5');
			retHash.setEncoding('base64');			
			// Создать поток для чтения файла
			var rs = fs.createReadStream(item[0]);
			//
			rs.on('data', function (data) {
				retHash.update(data);
			});
			// Вещаем обработчик на окончание чтения данных из файлового потока
			rs.on('end', function () {
				// Уменьшить количество обрабатываемых файлов
				dm5Calc--;
				// Вызвать функцию обратного вызова
				item[1](retHash.digest('base64'));
				// Вызвать функцию для запуска обработки следующего файла
				_get_md5_file();
			});

Хотя я-то надеялся на другой результат. Надеюсь, в комментариях мне укажут на ошибки. Также я ввел ограничение на количество одновременно обрабатываемых файлов для расчета контрольной суммы. Большое значение не дает существенный выигрыш в скорости, зато при некотором пороговом значении (у меня оно оказалось = 2,5 тыс.) программа падала с ошибкой:

events.js:72
        throw er; // Unhandled 'error' event
              ^
Error: EMFILE, open '<какое-то имя файла>'

2. Определяем файлы, которые изменились

Данный блок полностью реализуется на javascript и рассматривать его подробно мы не будем. Ничего сложного в сравнении двух объектов с атрибутами элементов нет. Стоит только упомянуть, что нам достаточно сравнить старые и новые атрибуты директории, чтобы понять, есть в ней изменения или нет. И не стоит забывать, что если элемента нет в старой версии, значит, он был добавлен, а значит, должен попасть в список изменившихся элементов.

Все исходники упоминаемые в статье лежат здесь. Также в архиве есть файл test.js для запуска тестов замера времени (только не забудьте изменить путь на тот что есть у вас на диске).

В следующей статье мы рассмотрим блок номер 3. Там мы будем использовать потоки Transform для упаковки/распаковки и шифрования/дешифрования данных. А также сделаем интерфейс для работы с разными хранилищами backup-ов.

Автор: shasoft

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js