Сегодня у меня две причины пробежаться по клавишам.
Во-первых, после того, как на прошлой неделе я перевёл документацию по jParser (после ознакомления с RReverserовским примером применения jParser при анализе BMP-файлов), мне представляется уместным перейти к напрашивающемуся последующему шагу: развить тему, поделиться с читателями моим собственным примером применения jParser для анализа несколько более сложной структуры данных. (Отчасти это станет ответом на вопрос, который alekciy задал, интересуясь дальнейшими примерами практического использования jParser.)
Во-вторых, ≈полгода назад (26 ноября 2011 года) ertaquo поинтересовался, зачем мне хочется использовать Node.js в Фидонете. Тогда я сообщил, что мне просто нравится название (помню те времена, когда термин «node» или «нóда», если употреблялся без уточнения, в российском околокомпьютерном мире по умолчанию означал узел Фидонета), но не мог привести никакого наглядного примера работающего кода, а сейчас приведу.
Итак, пример будет двойным. Предлагаю вашему вниманию анализ заголовков писем фидонетовской эхопочты, хранимой в формате JAM. Этот формат популярен в Фидонете со времён далёких и незапамятных (в Википедии говорится, что появление JAM относится к 1993 году). Сразу скажу, что давно предпочитаю JAM другому популярному формату (Squish), потому что этот последний хранит в заголовке у письма идентификаторы не более чем девяти откликов на него, тогда как JAM вместо массива ограниченной длины использует более гибкую структуру данных (связный список), так что позволяет выстроить полное дерево ответов даже в самых оживлённых и разветвлённых обсуждениях.
Документацию по JAM можно без труда найти на разных фидошных BBS, но BBS свойственно со временем закрываться или менять адреса, так что для надёжности сошлюсь на собственное письмо пятилетней давности, в котором я эту документацию цитировал дословно и целиком. (Чешская BBS, которая тогда послужила мне источником, сейчас ужé закрылася. Призрачно всё в этом мире бушующем.)
Как там видно, заголовки писем фидонетовской эхопочты хранятся внутри JHR-файла. Этот файл состоит из заголовка фиксированной длины (FixedHeaderInfoStruct), за которым следуют собственно заголовки писем (MessageHeader), каждый из которых состоит, опять же, из структуры фиксированного размера (MessageFixedHeader) и переменного хвоста, состоящего из нескольких полей (SubFieldXX), общая длина которых задаётся в поле SubfieldLen внутри структуры MessageFixedHeader. Поле SubFieldXX опять же состоит из заголовка фиксированного размера, за которым следует строка байтов, длина которой задана в предшествующем ей числе datlen. (Это напоминает реализации строк в диалектах языка Паскаль, распространённых в те же девяностые годы — Tурбо-Паскаль, UCSD Pascal; однако же в Паскале длина указывалася одним байтом, а в JAM число datlen имеет тип ulong, то есть оно тридцатидвухбитно. Это предусмотрительно.)
Гораздо менее видно другое важное обстоятельство: внутри JHR-файла заголовки MessageHeader не обязательно следуют встык друг за другом. В подразделе «Updating message headers» указывается, что если после редактирования или обработки письма его заголовок вырастает в объёме, то он помещается в конец файла, а прежний заголовок помечается как удалённый. О судьбе писем, чей заголовок не вырос в объёме, а уменьшился, там не сказано ничего — однако на практике многие фидонетовские программы записывают такой новый заголовок на место прежнего, соответствующим образом изменяя значение SubfieldLen (а при необходимости и отдельные значения datlen). Между этим и последующим MessageHeaderом остаётся мусор, состоящий из содержимого прежних последних полей SubFieldXX. Вот почему после прочтения очередного заголовка MessageHeader не найдётся никакого более разумного способа перейти к последующему заголовку MessageHeader, кроме поиска строки из трёх ASCII-символов «JAM» с последующим нулевым байтом — это последовательность Signature, с которой обязан начинаться заголовок MessageFixedHeader.
Код модуля для Node.js, читающего заголовки эхопочты из JHR-файла в оперативную память, можно поэтому набросать нижеследующим образом:
var fs = require('fs');
var jParser = require('jParser');
var ulong = 'uint32';
var ushort = 'uint16';
var JAM = function(echotag){
if (!(this instanceof JAM)) return new JAM(echotag);
this.echotag = echotag;
// Buffers:
this.JHR = null;
/*
this.JDT = null;
this.JDX = null;
this.JLR = null;
*/
}
JAM.prototype.readJHR = function(callback){ // (err)
if (this.JHR !== null) callback(null);
fs.readFile(this.echotag+'.JHR', function (err, data) {
if (err) callback(err);
this.JHR = data;
callback(null);
});
}
JAM.prototype.ReadHeaders = function(callback){ // err, struct
this.readJHR(function(err){
if (err) callback(err);
var thisJAM = this;
var parser = new jParser(this.JHR, {
'reserved1000uchar': function(){
this.skip(1000);
return true;
},
'JAM0' : ['string', 4],
'FixedHeaderInfoStruct': {
'Signature': 'JAM0',
'datecreated': ulong,
'modcounter': ulong,
'activemsgs': ulong,
'passwordcrc': ulong,
'basemsgnum': ulong,
'RESERVED': 'reserved1000uchar',
},
'SubField': {
'LoID': ushort,
'HiID': ushort,
'datlen': ulong,
'Buffer': ['string', function(){ return this.current.datlen }]
/*
'type': function(){
switch( this.current.LoID ){
case 0: return 'OADDRESS'; break;
case 1: return 'DADDRESS'; break;
case 2: return 'SENDERNAME'; break;
case 3: return 'RECEIVERNAME'; break;
case 4: return 'MSGID'; break;
case 5: return 'REPLYID'; break;
case 6: return 'SUBJECT'; break;
case 7: return 'PID'; break;
case 8: return 'TRACE'; break;
case 9: return 'ENCLOSEDFILE'; break;
case 10: return 'ENCLOSEDFILEWALIAS'; break;
case 11: return 'ENCLOSEDFREQ'; break;
case 12: return 'ENCLOSEDFILEWCARD'; break;
case 13: return 'ENCLOSEDINDIRECTFILE'; break;
case 1000: return 'EMBINDAT'; break;
case 2000: return 'FTSKLUDGE'; break;
case 2001: return 'SEENBY2D'; break;
case 2002: return 'PATH2D'; break;
case 2003: return 'FLAGS'; break;
case 2004: return 'TZUTCINFO'; break;
default: return 'UNKNOWN'; break;
}
}
*/
},
'MessageHeader': {
'Signature': 'JAM0',
'Revision': ushort,
'ReservedWord': ushort,
'SubfieldLen': ulong,
'TimesRead': ulong,
'MSGIDcrc': ulong,
'REPLYcrc': ulong,
'ReplyTo': ulong,
'Reply1st': ulong,
'Replynext': ulong,
'DateWritten': ulong,
'DateReceived': ulong,
'DateProcessed': ulong,
'MessageNumber': ulong,
'Attribute': ulong,
'Attribute2': ulong,
'Offset': ulong,
'TxtLen': ulong,
'PasswordCRC': ulong,
'Cost': ulong,
'Subfields': ['string', function(){ return this.current.SubfieldLen; } ],
/*
'Subfields': function(){
var final = this.tell() + this.current.SubfieldLen;
var sfArray = [];
while (this.tell() < final) {
sfArray.push( this.parse('SubField') );
}
return sfArray;
},
*/
'AfterSubfields': function(){
var initial = this.tell();
var bytesLeft = thisJAM.JHR.length - initial - 4;
var seekJump = 0;
var sigFound = false;
var raw = this;
if (bytesLeft <= 0) return 0;
do {
this.seek(initial + seekJump, function(){
var moveSIG = raw.parse('JAM0');
if (moveSIG === 'JAM') {
sigFound = true;
/*
if (seekJump > 0){
console.log(
'initial = ' + initial +
', seekJump = ' + seekJump +
', moveSIG = ' + moveSIG
);
}
*/
}
});
seekJump++;
} while (!sigFound && (seekJump < bytesLeft) );
this.skip(seekJump-1);
return seekJump-1;
}
},
'JHR': {
'FixedHeader': 'FixedHeaderInfoStruct',
'MessageHeaders': function(){
var mhArray = [];
while (this.tell() < thisJAM.JHR.length - 69) {
mhArray.push( this.parse('MessageHeader') );
}
return mhArray;
}
}
});
callback(null, parser.parse('JHR'));
});
}
module.exports = JAM;
В этом наброске используется кэширование сырых данных из JHR-файла внутри экспортируемого объекта JAM (в поле JHR) — решение неэкономное с точки зрения нынешней конструкции модуля, но оно пригодится, если наряду с методом ReadHeaders понадобится более простой метод, который читал бы, например, только заголовок FixedHeaderInfoStruct. Там же предусмотрены поля и для трёх остальных файлов формата JAM (для JDT, и JDX, и JLR), но закомментированы. (В идеале следовало бы следить и за актуальностью кэша — делать stat(), а не то и вовсе watchFile() — но понятно, что для первоначального наброска модуля этот код сгодится и без того.)
Типы данных из документации JAM (например, ulong) заданы не средствами jParser (например, «'ulong': 'uint32'»), а объявлены как переменные JavaScript (например, «var ulong = 'uint32'»), значения которых используются в описании структур данных. Это для скорости: понятно, что код джаваскриптового движка V8 сработает гораздо быстрее, нежели код модуля jParser.
В описании структуры SubField вы обнаружите закомментированное поле type — оно заполняется джаваскриптовой функцией, содержащей мнемонические обозначения полей, заимствованные из документации по JAM. Может использоваться в целях отладки.
Поле Subfields внутри структуры MessageHeader определено двумя способами. Первый (быстрый) считывает это поле как строку байтов размером SubfieldLen. Второй (закомментированный) полностью обрабатывает это поле, вычленяя подполя посредством jParser — если приложению, использующему модуль, в любом случае понадобятся метаданные из переменной части заголовка фидопочты, то чего же откладывать их анализ в долгий ящик.
Поле AfterSubfields содержит простой поиск строки из трёх ASCII-символов «JAM» с последующим нулевым байтом — причина этого изложена в одном из предыдущих абзацев. Закомментированный вызов console.log() имеет отладочный смысл, не более. (Название внутренней переменной moveSIG является аллюзией на мем «All your base are belong to us».)
Число 69 в описании поля MessageHeaders в структуре JHR является «волшебным»; его цель в том, чтобы анализ не подбирался слишком близко к концу файла, где также можно ожидать мусорные данные.
Скорость анализа я проверил при помощи вот какого тестового скрипта:
var JAM = require('../');
var util = require('util');
console.log( new Date().toLocaleString() );
var blog = JAM('blog-MtW');
blog.ReadHeaders(function(err,data){
if (err) throw err;
//console.log( util.inspect(data, false, Infinity, false) );
console.log( new Date().toLocaleString() );
});
Скрипт лежит в подкаталоге test, поэтому в первой строке использует обращение к родительскому каталогу, где текст основного модуля лежит в файле index.js; так как это имя подразумевается по умолчанию в Node.js, то достаточно указать только родительский каталог.
Тестовые данные в файле blog-MtW.jhr содержат заголовки блогозаписей моей фидонетовской блогоэхи (Ru.Blog.Mithgol), накопившиеся с марта 2007 года.
Прогон теста на одноядерном Pentium IV (2,2 ГГц) показывает, что заголовки обрабатываются за три-четыре секунды. Если же простое считывание массива Subfields заменить на его анализ, то это время ещё удваивается.
Это много для одной эхоконференции, потому что на узле Фидонета таких эхоконференций легко может быть больше сотни, и общее время анализа заголовков эхопочты получится многоминутным.
А ведь фидошникам наверняка не надо напоминать, что популярный фидонетовский редактор почты GoldED (GoldED+, GoldED-NSF) сканирует эхоконференции (в начале своей работы) гораздо быстрее, и их имена мелькают в строке статуса на его заставке так быстро, что нетрудно видеть — на каждую тратятся доли секунды, не более. Приходится поневоле прийти к пренеприятному выводу: джаваскриптовый анализ двоичных данных, даже на быстром движке V8, работает на порядок медленнее — а не то и ещё медленнее, чем на порядок.
Остаётся разве что цинично заподозрить, что GoldED в начале работы считывает для быстроты не весь файл, а только одну заголовочную структуру FixedHeaderInfoStruct (данных из неё хватило бы для вывода числа сообщений в эхоконференциях, а больше GoldED в начале работы ничего и не делает) — правда, подозрение это я никак не могу ни подтвердить, ни опровергнуть, потому что в CVS GoldED+ не имел времени разобраться.
Код своего модуля (читальника заголовков JAM) я выложил на Гитхабе под свободной лицензией MIT.
Автор: Mithgol