Node.js на узле Фидонета: читаем джаваскриптом заголовки эхопочты, хранимой в формате JAM

  • Tutorial
Сегодня у меня две причины пробежаться по клавишам.

Во-первых, после того, как на прошлой неделе я перевёл документацию по 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\0') {
                        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.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 15

    +3
    Яваскрипт не рассчитан на парсинг бинарных данных, вот и работает так медленно. Да и я бы не сказал, что на 386 машине голый дедушка так быстро читал имена эх :-)
    А вообще, пример хороший. Но только как пример — ведь Фидо уже не оживить.
      0
      А я вот думаю, что оживить.
        0
        Причём всё, что для этого потребуется — это достаточная (критическая) масса открытого и свободного исходного кода, написанного не через задницу, снабжённого комментариями и достаточными пояснениями.
          0
          Большая часть современных людей просто не поймет, зачем нужны какие-то эхи и ноды (да еще и ZMH/MMH держать — вроде это в полиси осталось еще?), да для всего этого какие-то программы качать да настраивать. Есть ведь интернет, есть форумы и почта! Это естественная эволюция.
            +2
            Успех Фидо ведь зависит не от количества и качества софта, а от энтузиастов и пользователей. Да и ритм общения в сетях изменился по сравнению с концом 90х.
              0
              Пользовался Фидо с 96го года, потом перестал.
              Так что и согласен, и не согласен с тезисом.
              Дело не в самой технологической стороне («критической массе кода»), а в возможностях, которые эта масса реально бы давала вполне конкретному массовому пользователю. Будет пользователю легко и просто (хотя бы не сложнее чем с IRC, eMule, BitTorrent, jabber, форумами, группами новостей yahoo, google и т.п.) и в то же самое время — интересно, тогда будут пользоваться. Точно так же, как упомянутыми сервисами. А если это будет «упражнение для гиков», не подкрепляемое полезным результатом — не будут.
                0
                Стало быть, в 1996 году Вы начали пользоваться Фидонетом. А когда закончили — и, главное, из-за чего? Что стало основною причиною ухода?
                  +3
                  Закончил в 2001м. Если грубо — потому что перестало быть интересно, потому что сервисы в Интернет были богаче возможностями и более наполнены информацией, которая как раз интересна была. Плюс оперативность доступа к ней и наличие поиска.

                  Условно — чтобы узнать что-то в Фидо, нужно было подписаться на соответствующую конференцию, задать вопрос, и надеяться на удачу, что кто-то расскажет, или сделать рескан (если это было возможно) и качать кучу старых сообщений, чтобы потом в них что-то найти. А в Интернет было достаточно пойти на altavista или еще куда, и искомое получал за минуты…

                  E-mail, опять же, даже тогда был куда оперативнее нетмейла (не все узлы ведь круглосуточны), плюс для оперативного общения был icq. Тогда еще активно пользовался irc (искал варез, обсуждал что-то в реальном времени, а не растягивая разговор из десяти реплик на неделю).

                  Так что в принципе, мне было все равно, Фидо с его сервисами, или Интернет — вопрос был в контенте и в оперативности доступа к нему. Финансовый вопрос решался просто — Интернет был на работе, а ограничений на личное использование мне вполне хватало, так что бесплатность Фидо роли не играла.

                  Сейчас, в свете развития p2p-технологий и прочего, возможно, реализация похожей на ftn системы имела бы смысл (поверх IP и BitTorrent-протокола со специфическими функциями трекеров, например), но с технологической точки зрения — уже не в форме слепого копирования старых принципов и традиций.
                    +2
                    Тоже отвечу, для полноты картины.

                    Я пользовался с 2001 до 2007, причём с 2005 — на КПК (Qusnetsoft NewsReader) через fido7 и google.groups. Было приятное общение с интересными людьми. Постепенно основная масса общения плавно перетекла на форумы (за счёт больше оперативности и удобства). Сейчас даже затрудняюсь представить причины для возвращения: основное ведь общение и аудитория.

                    P.S. Принимал активное участие в жизни одного крупного (в своё время. От зарождения, до пика и постепенного упадка) форума по КПК и однозначно могу сказать, что главное — это люди и интерес, а не техническая платформа и софт. Последнее — это просто приятное дополнение, облегчающее общение.
            +7
            Угадай автора по заголовку [x]
              0
              Я ещё увидев в ленте VK эту собаку с дискетой почуял неладное…
              +1
              А стоит ли вообще использоваться какие-либо специфичные форматы месседжбаз, когда любая локальная база данных (тот же SQLite, например), справится с хранением куда легче?
                0
                Можно распарсить старые данные и занести их в базу данных.
                  0
                  Это соображение справедливо.

                  Состояние обстоятельств объясняется историческими причинами: появление SQLite относится к 2000 году, и к тому времени формат JAM существовал ужé ≈семь лет и поддерживался несколькими десятками наиболее распространённых программ, авторам которых возёхаться и переходить на SQLite было влом.

                  Следовательно, чтобы внедрить SQLite в Фидонете (а разумность этого шага представляется мне очевидною), необходимо, по меньшей мере, продумать структуру базы данных, обеспечить её поддержку хотя бы одним удобным тоссером (эхопроцессором) и хотя бы одним удобным редактором почты, а также обеспечить преобразование накопившейся эхопочты из существующих популярных форматов (прежде всего — JAM, Squish, MSG Opus, FIPS) в эту структуру.

                  Модуль чтения эхопочты из JAM — причём всей эхопочты, а не только её заголовков, как в моём первоначальном наброске — это необходимая составная часть такого преобразователя.

                  Другая необходимая составная часть — запись в БД SQLite. Но её со временем обеспечит, например, модуль node-sqlite3: его код открыт, так что его можно будет устанавливать на Windows простой командою «npm install sqlite3», если проблема 67 будет решена (до тех же пор установка требует заодно и компиляции, так что наряду с Node.js и npm для неё требуется также наличие Python и Microsoft Visual Studio — вряд ли это придётся по нраву современным фидошникам).
                  +2
                  В Киеве 27 мая как раз «мемориальная» встреча в 12 на Почтовой планируется.
                  Поплакал утром, когда посмотрел 340 килобайтный нодлист. В 2:463 — 32 ноды… в половине регионов — по одному хосту…

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