Давно искал программу для сохранения своих проектов. При этом обязательным требованием было:
1. Сохранять в хранилище только изменившиеся файлы;
2. Упаковывать изменившиеся файлы;
3. Быть бесплатной.
Однако поиск ничего не дал. Вернее, поиск дал, но обычно один из пунктов отсутствовал. Поэтому я решил написать свою, а заодно «пощупать вживую» NodeJS. За 2 дня написал. И даже прикрутил к программе шифрование. Что не вызвало особых сложностей, так как модуль шифрования входит в стандартную поставку ноды. При этом сделал модули упаковки, шифрования и работы с хранилищем расширяемыми. Чтобы можно было просто добавлять новые возможности, расширяя функционал. В текущей версии сохранение работает только в файловую систему, без упаковки, но с шифрованием.
Посмотреть справку по использованию можно тут.
На этом можно бы и закончить – дело сделано. Но есть один минус – программа работает СИНХРОННО. И хотя работает достаточно шустро (если не сохранять гигабайты информации), но всё таки нода ориентирована на АСИНХРОННУЮ работу. Так что я посмотрел Скринкаст по Node.JS и решил сделать все по «правильному», c учетом особенностей ноды.
Раз уж решил писать «правильно», то начну с общей схемы работы:
На схеме ясно видно, какие операции можно выполнять параллельно, а какие нет.
Начнем реализовывать блоки. Каждый блок будем писать в отдельный файл, чтобы было понятнее и нагляднее.
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