Как да анализирате PDF файлове в мащаб в NodeJS: какво да правите и какво да не правите

Направете стъпка в програмната архитектура и научете как да направите практично решение за истински бизнес проблем с NodeJS Streams с тази статия.

Вашият участник, след като ги запазите безброй часове, пренасяйки PDF файлове, за да получите техните данни. (Източник: GIPHY)

Заедно: Механика на течностите

Една от най-големите силни страни на софтуера е, че можем да разработваме абстракции, които ни позволяват да разсъждаваме върху кода и да манипулираме данните по начини, които можем да разберем. Потоците са един такъв клас абстракция.

В простата механика на флуидите концепцията за потока е полезна за разсъждения за начина, по който ще се движат частиците на течността и ограниченията, прилагани към тях в различни точки на системата.

Например, да речем, че по една тръба равномерно тече вода. На половината път на тръбата се разклонява. По принцип водният поток ще се раздели равномерно на всеки клон. Инженерите използват абстрактната концепция на потока, за да разсъдят за свойствата на водата, като нейния дебит, за произволен брой разклонения или сложни конфигурации на тръбопровода. Ако попитате инженер каква е предположението, че ще бъде дебитът през всеки клон, той правилно ще отговори интуитивно с "едната половина". Това се разширява до произволен брой потоци математически.

Концептуално потоците трябва да кодират какви потоци са твърде течна механика. Можем да разсъждаваме за данните във всеки даден момент, като ги разглеждаме като част от поток. Вместо да се притеснявате за подробности за внедряването между това как се съхранява. Може би бихте могли да обобщите това с някаква универсална концепция за тръбопровода, която можем да използваме между дисциплините. На ум ви идва търговската фуния, но това е тангенциално и ще я покрием по-късно. Най-добрият пример за потоци и един, с който абсолютно трябва да се запознаете, ако още не сте UNIX тръби:

cat server.log | греп 400 | по-малко

Ние нежно наричаме | характер тръба. Въз основа на неговата функция ние извеждаме изхода на една програма като вход на друга програма. Ефективно настройване на тръбопровод.

(Също така изглежда като тръба.)

Ако сте като мен и се чудите в този момент защо това е необходимо, попитайте се защо използваме тръбопроводи в реалния живот. По същество това е структура, която елиминира съхранението между точките за обработка. Не е нужно да се притесняваме за съхраняването на бъчви с масло, ако е изпомпано.

Преценете това в софтуера. Умните разработчици и инженери, написали кода за тръбни данни, го настройват така, че той никога не заема твърде много памет на машина. Колкото и голям да е логфайлът отгоре, той няма да окачи терминала. Цялата програма е процес, който обработва безкрайно малки точки от данни в поток, а не контейнери от тези точки. Logfile никога не се зарежда в паметта наведнъж, а по-скоро в управляеми части.

Не искам да изобретявам колелото тук. Така че сега, когато покрих метафора за потоци и обосновката за използването им, Flavio Copes има страхотна публикация в блога, обхващаща начина, по който те се реализират в Node. Отделете толкова време, колкото е необходимо да покриете основните положения там, и когато сте готови, върнете се и ние ще разгледаме случай на употреба.

Положението

И така, сега, когато имате този инструмент в лентата с инструменти, представете това:

Вие сте на работа и вашият мениджър / юридически / HR / вашият клиент / (включете заинтересования участник тук) се обърна към вас с проблем. Те прекарват твърде дълго време в преглеждането на структурирани PDF файлове. Разбира се, обикновено хората няма да ви кажат такова нещо. Ще чуете „Прекарвам 4 часа във въвеждане на данни.“ Или „Поглеждам през ценовите таблици.“ Или „Попълвам правилните формуляри, така че да получаваме моливи на фирмата си на всяко тримесечие“.

Каквото и да е, ако работата им включва както (а) четене на структурирани PDF документи, така и (б) масово използване на тази структурирана информация. След това можете да влезете и да кажете: „Ей, може да успеем да автоматизираме това и да освободим времето ви за работа върху други неща“.

Безпроблемна скорост. Кодът ви е аромат, сега поздравете телевизионната си реклама. (Източник: Крис Пейтърс)

Така че, за тази статия, нека да измислим манекенска компания. Откъдето идвам, терминът "манекен" се отнася или за идиот, или за биберон. Нека си представим тази фалшива компания, която произвежда биберони. Докато сме в това, нека скочим от акулата и казваме, че са отпечатани 3D. Компанията работи като етичен доставчик на биберони за нуждаещите се, които сами не могат да си позволят първокласни неща.

(Знам колко глупо звучи, спрете неверието си, моля.)

Тод източници на печатни материали, които влизат в продуктите на DummEth, и трябва да гарантира, че те отговарят на три ключови критерия:

  • те са пластмасова храна, за да запазят здравето на бебетата,
  • те са евтини, за икономично производство и
  • те се снабдяват възможно най-близо, за да подкрепят маркетинговото копие на компанията, заявявайки, че тяхната верига за доставки също е етична и замърсява възможно най-малко.

Проектът

Така че е по-лесно да следвате, създадох репо за GitLab, което можете да клонирате и използвате. Уверете се, че вашите инсталации на Node и NPM също са актуални.

Основна архитектура: ограничения

Сега какво се опитваме да направим? Да приемем, че Тод работи добре в електронни таблици, като много офис работници. За да Тод да сортира пословичната триизмерна печатна пшеница от плявата, за него е по-лесно да преценява материалите по хранителен клас, цена на килограм и местоположение. Време е да зададете някои ограничения на проекта.

Да приемем, че степента на храна за даден материал е оценена по скалата от нула до три. С нулево значение забранените в Калифорния богати на BPA пластмаси. Три значения, често използвани незамърсяващи материали, като полиетилен с ниска плътност. Това е чисто, за да опростим нашия код. В действителност би трябвало по някакъв начин да съпоставим текстовите описания на тези материали (напр. „LDPE“) в степен на храна.

Цена за килограм можем да приемем като свойство на материала, даден от неговия производител.

Местоположение, ние ще опростим и ще приемем, че е просто относително разстояние, докато врана лети. В противоположния край на спектъра има надстроеното решение: използвайки някакъв API (напр .: Google Maps), за да откриете приблизителното разстояние на пътуване, което даден материал би изминал, за да стигне до центъра за разпространение на Тод. Така или иначе, нека кажем, че сме го предоставили като стойност (километри до Тод) в PDF файловете на Тод.

Освен това, нека разгледаме контекста, в който работим. Todd ефективно работи като събирач на информация на динамичен пазар. Продуктите влизат и излизат и техните детайли могат да се променят. Това означава, че имаме произволен брой PDF файлове, които могат да се променят - или по-точно, да бъдат актуализирани - по всяко време.

Така че въз основа на тези ограничения, най-накрая можем да разберем какво искаме да изпълни кодът ни. Ако искате да изпробвате дизайнерската си способност, направете пауза тук и помислете как да структурирате решението си. Може да не изглежда като това, което ще опиша. Това е добре, стига да осигурите здравословно работещо решение за Тод и нещо, което не бихте разкъсали по-късно, като се опитвате да поддържате.

Основна архитектура: Решения

Така че имаме произволен брой PDF файлове и някои правила как да ги анализираме. Ето как можем да го направим:

  1. Настройте обект Stream, който може да чете от някакъв вход. Като HTTP клиент, който изисква изтегляне на PDF. Или модул, който написахме, който чете PDF файлове от директория във файловата система.
  2. Настройте посредник буфер. Това е като сервитьорът в ресторант, който доставя готово ястие на желания от него клиент. Всеки път, когато пълен PDF файл попадне в потока, ние прехвърляме тези парчета в буфера, за да могат да бъдат транспортирани.
  3. Сервитьорът (буфер) доставя храната (PDF данни) на клиента (нашата функция Parsing). Клиентът прави каквото пожелае (преобразува се в някакъв формат за електронни таблици) с него.
  4. Когато клиентът (Parser) е готов, уведомете сервитьора (Buffer), че е свободен и може да работи по нови поръчки (PDF файлове).

Ще забележите, че няма ясен край на този процес. Като ресторант, комбото ни Stream-Buffer-Parser никога не завършва, докато, разбира се, няма повече данни - няма повече поръчки.

Сега знам, че все още няма лиза от код. Това е от решаващо значение. Важно е да можем да разсъждаваме за нашите системи преди да ги напишем. Сега няма да оправим всичко от първия път дори и с априорни разсъждения. Нещата винаги се чупят сред природата. Грешки трябва да бъдат отстранени.

Това каза, че е мощно упражнение за сдържаност и предвидливост да планирате кода си преди да го напишете. Ако можете да опростите системи с нарастваща сложност в управляеми части и аналогии, ще можете да увеличите производителността си експоненциално, тъй като когнитивният стрес от тези сложности избледнява в добре проектирани абстракции.

Така че в голямата схема на нещата изглежда така:

Първоначалната концепция на нашата програма. Няма да изглежда така, когато е направено, но процесът на решаване на проблеми е също толкова важен, колкото и резултатът. Окръглено в зелено: какво ще направим следващото.

Въвеждане на зависимости

Сега като отказ от отговорност, трябва да добавя, че има цял мисъл около въвеждането на зависимости във вашия код. Ще се радвам да разгледам тази концепция в друга публикация. Междувременно нека само да кажа, че един от основните конфликти в играта е този между желанието ни да свършим работата си бързо (т.е.: да избегнем синдрома на NIH) и желанието ни да избегнем риск от трети страни.

Прилагайки това към нашия проект, реших да разтоваря по-голямата част от нашата PDF обработка към модула pdfreader. Ето няколко причини, поради които:

  • Тя беше публикувана наскоро, което е добър знак, че репото е актуален.
  • Той има една зависимост - тоест това е просто абстракция над друг модул - който редовно се поддържа в GitHub. Само това е чудесен знак. Освен това зависимостта, модул, наречен pdf2json, има стотици звезди, 22 сътрудници и много очни ябълки, които я следят внимателно.
  • Поддръжникът, Адриан Джоли, прави добри счетоводни услуги в проследяването на издания на GitHub и активно се грижи за въпросите на потребителите и разработчиците.
  • При одит чрез NPM (6.4.1) не се откриват уязвимости.

Така че като цяло, изглежда като сигурна зависимост да се включи.

Сега модулът работи по съвсем лесен начин, въпреки че неговият README не описва изрично структурата на неговия изход. Скалата отбелязва:

  1. Той излага класа PdfReader да бъде инстанциран
  2. Този случай има два метода за анализ на PDF. Те връщат един и същ изход и се различават само във входа: PdfReader.parseFileItems за име на файл и PdfReader.parseBuffer от данни, които не искаме да препращаме от файловата система.
  3. Методите изискват обратно повикване, което се извиква всеки път, когато PdfReader намери това, което обозначава като PDF елемент. Има три вида. Първо, метаданните на файла, които винаги са първият елемент. На второ място са метаданните на страницата. Той действа като връщане на превоз за координатите на текстовите елементи, които ще бъдат обработени. Последно е текстови елементи, които можем да мислим като прости обекти / структури със свойство на текст и 2D AABB координати с плаваща запетая на страницата.
  4. От нашето обратно извикване е да обработим тези елементи в структура от данни по наш избор, както и да обработваме всички грешки, хвърлени към нея.

Ето пример за кодов фрагмент:

const {PdfReader} = изисквам ('pdfreader');
// Инициализирайте читателя
const reader = нов PdfReader ();
// Прочетете някои произволно дефиниран буфер
читател.parseBuffer (буфер, (грешка, елемент) => {
  ако (грешка)
    console.error (ERR);
  друго, ако (! артикул)
    / * pdfreader изпраща опашки към елементите в PDF файла и ги предава
     * обратното обаждане Когато не се предава нито един елемент, това е индикация за това
     * приключихме с четенето на PDF. * /
    console.log ( "Готово".);
  иначе ако (item.file)
    // Елементите на файла се отнасят само за файла на PDF файла.
    console.log (`Разбор на $ {item.file && item.file.path || 'буфер'}`)
  иначе ако (item.page)
    // Елементите на страниците просто съдържат номера на тяхната страница.
    console.log (`Достигната страница $ {item.page}`);
  иначе ако (item.text) {
    // Текстовите елементи имат още няколко свойства:
    const itemAsString = [
      item.text,
      'x:' + item.x,
      'y:' + item.y,
      'w:' + item.width,
      'h:' + височина на артикула,
    ] .Join ( "\ п \ т ');
    console.log ('Елемент на текста:', itemAsString);
  }
});

PDF файлове на Тод

Нека се върнем към ситуацията с Тод, само за да предоставим някакъв контекст. Искаме да съхраняваме бибероните на базата на данни на базата на три ключови критерия:

  • хранителния им клас, за да запазят здравето на бебетата,
  • тяхната цена, за икономично производство, и
  • разстоянието им до Тод, за да подкрепят маркетинговото копие на компанията, заявявайки, че тяхната верига за доставки също е етична и замърсява възможно най-малко.

Твърдо кодирах прост скрипт, който рандомизира някои манекен продукти и можете да го намерите в директорията / data на придружаващия репо за този проект. Този скрипт пише, че рандомизирани данни в JSON файлове.

Там има и документ за шаблон. Ако сте запознати с двигатели за шаблони като дръжки, ще разберете това. Има онлайн услуги - или ако се чувствате приключенски, можете да прехвърлите свои собствени - които вземат JSON данни и попълват шаблона и ви ги връщат като PDF. Може би за пълнота можем да опитаме това в друг проект. Както и да е: Използвах такава услуга за генериране на фиктивни PDF файлове, които ще анализираме.

Ето как изглежда човек (допълнително бяло пространство е изрязано):

Бихме искали да извлечем от този PDF някакъв JSON, който ни дава:

  • идентификационния номер и дата на реквизита за целите на счетоводството,
  • SKU на биберона, за уникална идентификация, и
  • свойствата на биберона (име, клас храна, единична цена и разстояние), така че Тод всъщност може да ги използва в работата си.

Как да направим това?

Четене на данните

Първо нека да настроим функцията за четене на данни от един от тези PDF файлове и извличане на PDF елементи на pdfreader в използваема структура на данни. За сега нека имаме масив, представящ документа. Всеки елемент от масива е обект, представляващ колекция от всички текстови елементи на страницата в индекса на този обект. Всяко свойство в обекта на страницата има y-стойност за своя ключ и масив от текстови елементи, намерени при тази y-стойност, за неговата стойност. Ето схемата, така че е по-лесно да се разбере:

Функцията readPDFPages в /parser/index.js се справя с това, подобно на примерния код, написан по-горе:

/ * Приема буфер (например: от fs.readFile) и анализира
 * то като PDF, което връща използваема структура на данни за
 * специфичен за приложения анализ на второ ниво
 * /
функция readPDFPages (буфер) {
  const reader = нов PdfReader ();
  // Връщаме обещание тук, като четене на PDF
  // операцията е асинхронна.
  върнете ново обещание ((разрешаване, отхвърляне) => {
    // Всеки елемент от този масив представлява страница в PDF
    нека страници = [];
    читател.parseBuffer (буфер, (грешка, елемент) => {
      ако (грешка)
        // Ако имаме проблем, изхвърлете!
        отхвърляне (ERR)
      друго, ако (! артикул)
        // Ако нямаме елементи, разрешете със структурата на данните
        разрешавате (страници);
      иначе ако (item.page)
        // Ако парсерът е достигнал нова страница, време е да
        // работи върху обекта на следващата страница в нашия масив от страници.
        pages.push ({});
      иначе ако (item.text) {
        // Ако НЕ имаме нов елемент на страницата, тогава имаме нужда
        // или да изтеглите, или да създадете нов масив "ред"
        // да представлява колекцията от текстови елементи у нас
        // текуща позиция Y, която ще бъде Y на този елемент
        // позиция.
        // Следователно, този ред се чете като,
        // "Или извлечете масива от редове за нашата текуща страница,
        // в сегашната ни позиция Y или направете нова "
        const ред = страници [pages.length-1] [item.y] || [];
        // Добавете елемента към референтния контейнер (т.е. редът)
        row.push (item.text);
        // Включете контейнера в текущата страница
        страници [pages.length-1] [item.y] = ред;
      }
    });
  });
}

Така че сега преминавайки PDF буфер в тази функция, ще получим някои организирани данни. Ето какво получих от пробен старт и го отпечатах в JSON:

[{'3.473': ['ИЗИСКВАНЕ НА ДЕТАЛИ НА ПРОДУКТА'],
    „4.329“: [„Дата: 23/05/2019“],
    '5.185': ['Идент. Номер на запитване: 298831'],
    „6.898“: [„Техника за биене“, „Todd Lerr“],
    '7.754': ['123 Example Blvd', 'DummEth Pty. Ltd.' ],
    '8.61': ['Timbuktu', '1337 Leet St'],
    '12 .235 ': [' SKU ',' 6308005 '],
    '13 .466 ': [' Име на продукта ',' Квадрат лимон кварцов биберон '],
    '14 .698 ': [' степен на храна ',' 3 '],
    '15 .928999999999998 ': [' $ / kg ',' 1.29 '],
    '17 .16 ': [' Местоположение ',' 55 ']}]

Ако погледнете внимателно, ще забележите, че в оригиналния PDF файл има правописна грешка. „Requisition“ е написано неправилно като „Requsition“. Красотата на нашия анализатор е, че не се грижим особено за грешки като тези във входящите ни документи. Стига да са структурирани правилно, можем да извличаме данни от тях точно.

Сега просто трябва да организираме това в нещо малко по-използваемо (сякаш ще го изложим чрез API). Структурата, която търсим, е нещо в тази насока:

{
  reqID: '000000',
  дата: „DD / MM / ГГГГ“, // Или нещо друго на базата на география
  sku: '000000',
  име: „Някои струни, които сме отрязали“,
  foodGrade: „X“,
  unitPrice: 'D.CC', // D за долари, C за центове
  местоположение: „XX“,
}

Встрани: Целостност на данните

Защо включваме числата като низове? Тя се основава на риска от разбор. Нека просто кажем, че принудихме всичките си числа към низове:

Единичната цена и местоположението биха били добре - в края на краищата те трябва да са с числа.

Оценката на храната за този много ограничен проект технически е безопасна. Никакви данни не се губят, когато ги принудим - но ако това е ефективно класификатор, като Enum, така че е по-добре да се съхранява като низ.

Идентификационният идентификатор и SKU обаче, ако бъдат принудени към низове, могат да загубят важни данни. Ако идентификационният номер за даден реквизит започне с три нули и го принудим към число, добре, ние просто сме загубили тези нули и събрахме данните.

Тъй като искаме целостта на данните, когато четем PDF файловете, просто оставяме всичко като String. Ако кодът на приложението иска да преобразува някои полета в числа, за да ги направи използваеми за аритметични или статистически операции, тогава ще оставим принудата да възникне в този слой. Тук просто искаме нещо, което анализира PDF файлове последователно и точно.

Преструктуриране на данните

Така че сега имаме информация за Тод, просто трябва да я организираме по използваем начин. Можем да използваме различни функции за манипулиране на масиви и обекти и тук MDN е ваш приятел.

Това е стъпката, при която всеки има свои собствени предпочитания. Някои предпочитат метода, който просто свършва работата и минимизира времето за разработка. Други предпочитат да разузнават най-добрия алгоритъм за работата (например: съкращаване на време за итерация). Добро упражнение е да видите дали можете да измислите начин да направите това и да го сравните с това, което имам. Бих искал да виждам по-добри, по-прости, по-бързи или дори просто различни начини за постигане на една и съща цел.

Както и да е, ето как го направих: функцията parseToddPDF в /parser/index.js.

функция parseToddPDF (страници) {
  страница const = страници [0]; // Знаем, че ще има само една страница
  // Декларативна карта на PDF данни, която очакваме, въз основа на структурата на Тод
  const polja = {
    // "Очакваме полето reqID да бъде на реда в 5.185, и
    // първи елемент в този масив "
    reqID: {ред: '5.185', индекс: 0},
    дата: {ред: '4.329', индекс: 0},
    sku: {ред: '12 .235 ', индекс: 1},
    име: {ред: '13 .466 ', индекс: 1},
    foodGrade: {ред: '14 .698 ', индекс: 1},
    единица цена: {ред: '15 .928999999999998 ', индекс: 1},
    местоположение: {ред: '17 .16 ', индекс: 1},
  };
  данни за const = {};
  // Присвойте данните на страницата на обект, който можем да върнем, според инструкциите
  // нашата спецификация на полета
  Object.keys (области)
    .forEach ((ключ) => {
      const field = полета [ключ];
      const val = страница [field.row] [field.index];
      // Ние не искаме да губим водещи нули тук и можем да се доверим
      // всяко приложение / обработка на данни, за да се тревожи за това. Това е
      // защо не принуждаваме към числото.
      данни [ключ] = val;
    });
  // Ръчно коригиране на някои текстови полета, така че те да могат да се използват
  data.reqID = data.reqID.slice ('Идент. код на запитване:'. дължина);
  data.date = data.date.slice ('Дата:'. дължина);
  данни за връщане;
}

Месото и картофите тук са в примката forEach и как го използваме. След като преди това извлечем Y позициите на всеки текстов елемент, е лесно да посочим всяко поле, което искаме като позиция в обекта на нашите страници. Ефективно предоставяне на карта, която да следвате.

Всичко, което трябва да направим тогава, е да декларираме обект на данни, за да изведем, да повторим над всяко посочено от нас поле, да следваме маршрута според нашата спецификация и да присвоим стойността, която намираме в края, на нашия обект с данни.

След няколко еднолинейни линии, за да спретнем някои низови полета, можем да върнем обекта на данни и сме тръгнали към състезанията. Ето как изглежда:

{reqID: '298831',
  дата: '23 / 05/2019 ',
  sku: '6308005',
  име: „Квадрат лимонов кварцов биберон“,
  foodGrade: '3',
  единица цена: '1,29',
  местоположение: '55'}

Поставяме всичко заедно

Сега ще преминем към създаване на известна съвместимост за този модул за разбор, за да можем да работим в мащаб и да разпознаем някои важни бариери за това. Диаграмата по-горе е чудесна за разбиране на контекста на логиката на анализа. Това не е много за разбирането как ще го паралелизираме. Можем да направим по-добре:

Тривиално, знам, и може би твърде учебник-узаконен за нас, за да използваме практически, но ей, това е основна концепция за формализиране.

Сега преди всичко трябва да помислим как ще се справим с входа и изхода на нашата програма, което по същество ще обгръща логиката на анализа и след това ще я разпределя сред процесите на парсера работници. Има много въпроси, които можем да зададем тук и много решения:

  • ще бъде ли приложение за команден ред?
  • Ще бъде ли последователен сървър с набор от крайни точки на API? Това има собствен набор от въпроси - REST или GraphQL, например?
  • Може би това е просто скелетен модул в по-широка кодова база - например, какво, ако генерализираме нашия синтаксичен анализ на набор от двоични документи и искаме да отделим модела на съвместимост от конкретния тип на изходния файл и внедряването на анализа?

За простота, аз ще обвивам логиката на анализа в полезност на командния ред. Това означава, че е време да направим куп предположения:

  • очаква ли файловите пътища като входни и дали те са относителни или абсолютни?
  • Или вместо това очаква ли свързаните PDF данни да бъдат включени?
  • Ще извежда ли данни във файл? Защото, ако е, тогава ще трябва да предоставим тази опция като аргумент за потребителя да посочи ...

Работа с въвеждането на командния ред

Отново, като запазя нещата възможно най-прости: избрах програмата да очаква списък с файлови пътища, или като индивидуални аргументи на командния ред:

индексен файл на възел-1.pdf файл-2.pdf… файл-n.pdf

Или тръби на стандартния вход като списък с файлови пътища, разделен с нов ред:

# прочетете редове от текстов файл с всички наши пътеки
котешки файлове-to-parse.txt | индекс на възела
# или може би просто ги избройте от директория
намерете ./data -name „* .pdf“ | индекс на възела

Това позволява на Node процеса да манипулира реда на тези пътища по всякакъв начин, който сметне за добре, което ни позволява да мащабираме кода за обработка по-късно. За да направим това, ще четем списъка с файлови пътища, независимо от начина, по който са предоставени, и ще ги разделим по произволен номер в под-списъци. Ето кода, методът getTerminalInput в ./input/index.js:

функция getTerminalInput (subArrays) {
  върнете ново обещание ((разрешаване, отхвърляне) => {
    const изход = [];
  
    ако (process.stdin.isTTY) {
      const input = process.argv.slice (2);
      const len ​​= Math.min (subArrays, Math.ceil (input.length / subArrays));
      докато (input.length) {
        output.push (input.splice (0, len));
      }
      решителност (изход);
    } else {
    
      нека въведе = '';
      process.stdin.setEncoding ( "UTF-8 ');
      process.stdin.on ('четим', () => {
        оставете парче;
        докато (chunk = process.stdin.read ())
          вход + = парче;
      });
      process.stdin.on ('край', () => {
        input = input.trim (). split ('\ n');
        const len ​​= Math.min (input.length, Math.ceil (input.length / subArrays));
        докато (input.length) {
          output.push (input.splice (0, len));
        }
        решителност (изход);
      })
    
    }
    
  });
}

Защо разгласявам списъка? Нека да кажем, че имате 8-ядрен процесор на хардуер за потребителски клас и 500 PDF файла за разбор.

За съжаление на Node, въпреки че борави с асинхронен код фантастично благодарение на контура си, той работи само на една нишка. За да обработите тези 500 PDF файла, ако не използвате многопоточен (т.е. многократен процес) код, използвате само една осма от капацитета си за обработка. Ако приемем, че ефективността на паметта не е проблем, можете да обработите данните до осем пъти по-бързо, като се възползвате от вградените паралелни модули на Node.

Разделянето на нашия принос на парчета ни позволява да правим това.

Като страна, това по същество е примитивен балансиращ натоварване и ясно предполага, че натоварванията, представени чрез анализ на всеки PDF файл, са взаимозаменяеми. Тоест, че PDF файловете са със същия размер и притежават същата структура.

Това очевидно е тривиален случай, още повече, че не вземаме предвид обработката на грешки в работните процеси и кой работник в момента е на разположение, за да се справи с нови товари. В случая, когато щяхме да настроим API сървър, който да обработва входящите заявки за анализ, ще трябва да вземем предвид тези допълнителни нужди.

Клъстериране на нашия код

Сега, след като разделените ни данни се разделят на управляеми натоварвания, несъмнено по измислен начин - с удоволствие бих искал да го рефактирам по-късно - нека разгледаме как можем да го групираме. Така че се оказва, че Node има два отделни модула за настройка на паралелен код.

Този, който ще използваме, клъстерният модул, по същество позволява на процеса Node да хвърля поща копия от себе си и да балансира обработката между тях, както сметне за добре.

Това е изградено отгоре на child_process модула, който е по-малко плътно съчетан с паралелизирането на самите програми на Node и ви позволява да хвърляте на хайвера други процеси, като програми за черупки или друг изпълним двоичен файл, и да взаимодействате с тях, използвайки стандартен вход, изход и т.н.

Горещо препоръчвам да прочетете документите на API за всеки модул, тъй като те са фантастично написани и дори да сте като мен и да намерите безцелно ръчно четене скучно и цялостно заета работа, поне се запознайте с въвеждането на всеки модул да ви помогне да се приземите в темата и да разширите познанията си за екосистемата на Node.

Така че нека преминем през кода. Ето го в насипно състояние:

const клъстер = изисквам ('клъстер');
const numCPUs = изискват ('os'). cpus (). дължина;
const {getTerminalInput} = изисквам ('./ вход');
(функция за асинхронизация main () {
  ако (cluster.isMaster) {
    const workerData = изчакайте getTerminalInput (numCPUs);
    за (нека i = 0; i 
      const работник = cluster.fork ();
      const params = {имена на файлове: workerData [i]};
      worker.send (PARAMS);
    }
  } else {
    изискват ( "./ работник");
  }
}) ();

Така че нашите зависимости са доста прости. Първо, има модула на клъстера, както е описано по-горе. Второ, ние изискваме os модула с изричната цел да разберем колко ядра на процесора има на нашата машина - което е основен параметър за разделяне на нашето натоварване. И накрая, има нашата функция за обработка на входните данни, която аз прехвърлих в друг файл за пълнота.

Сега основният метод всъщност е доста прост. Всъщност можем да го разделим на стъпки:

  1. Ако ние сме основният процес, разделете входящите ни данни, изпратени равномерно на броя на ядрата на процесора за тази машина
  2. За всеки товар, който трябва да бъде зареден, създава работник чрез cluster.fork и настройва обект, който можем да му изпратим по канала за съобщения на RPC на модула [клъстер] и да му изпратим проклетото нещо.
  3. Ако всъщност не сме основният модул, тогава трябва да сме работник - просто стартирайте кода в работния ни файл и го извикайте на ден.

Нищо лудо не става тук и това ни позволява да се съсредоточим върху реалното повдигане, което е да разберем как работникът ще използва списъка с имена, които му даваме.

Съобщения, асинхрон и потоци, всички елементи на питателна диета

Първо, както по-горе, позволете ми да зарежа кода, за който да се позовавате. Повярвайте ми, като го разгледате първо, ще ви позволи да прескочите всяко обяснение, което смятате за тривиално.

const Bufferer = изисквам ('../ bufferer');
const Parser = изисквам ('../ анализатор');
const {createReadStream} = изискване ('fs');
process.on ('съобщение', async (опции) => {
  const {filenames} = опции;
  const parser = нов Parser ();
  const parseAndLog = async (buf) => console.log (изчакайте parser.parse (buf) + ',');
  const parsingQueue = filenames.reduce (async (резултат, име на файла) => {
    очакваме резултат;
    върнете ново обещание ((разрешаване, отхвърляне) => {
      const reader = createReadStream (име на файл);
      const bufferer = нов Bufferer ({onEnd: parseAndLog});
      четец
        .pipe (bufferer)
        .once ('завършване', разрешаване)
        .once ('грешка', отхвърляне)
    
    });
  
  }, вярно);
  опитвам {
    изчакайте разбораQueue;
    process.exit (0);
  } улов (грешка) {
    console.error (ERR);
    process.exit (1);
  }
});

Сега има някои мръсни хакове тук, така че внимавайте, ако сте един от непосветените (само се шегувате). Нека разгледаме какво се случва първо:

Първата стъпка е да изисквате всички необходими съставки. Имайте предвид, това се основава на това, което прави самият код. Така че, нека само да кажа, че ще използваме персонализиран поток за писане, който с ужас наричам Bufferer, обвивка за нашата логика за разбор от последно време, също сложно наречен, Parser и добър стар надежден createReadStream от модула fs.

Сега ето къде се случва магията. Ще забележите, че всъщност нищо не е обвито във функция. Целият код на работника просто чака съобщение, за да стигне до процеса - съобщението от неговия господар с работата, която трябва да свърши за деня. Извинете средновековния език.

Така че можем да видим на първо място, че е асинхронен. Първо, ние извличаме имената на файловете от самото съобщение - ако това беше производствен код, щях да ги валидирам тук. Всъщност, по дяволите, бих ги потвърдил в нашия код за обработка на вход по-рано. Тогава ние инстанцираме нашия обект за разбор - само един за целия процес - това е така, за да можем да анализираме множество буфери с един набор от методи. Загрижеността ми е, че тя управлява паметта вътрешно и при размисъл, това е добро нещо, което да разгледаме по-късно.

След това има проста обвивка, parseAndLog около синтактичния анализ, който регистрира JSON-идентифицирания PDF буфер с прикрепена към него запетая, само за да улесни живота за свързване на резултатите от анализа на множество PDF файлове.

Вашият работник, подготвен и готов за среща със съдбата.

Накрая месото на материята, асинхронната опашка. Нека обясня:

Този работник получи своя списък с имена на файлове. За всяко име на файл (или път, наистина), трябва да отворим четим поток през файловата система, за да можем да получим PDF данните. След това трябва да хвърлим хайвера на нашия Bufferer (нашият сервитьор, следвайки аналогията на ресторанта по-рано), за да можем да транспортираме данните до нашия Парсер.

Буферерът е навит по поръчка. Всичко, което наистина прави, е да приеме функция, за да се обади, когато получи всички необходими данни - тук просто я молим да анализира и регистрира тези данни.

И така, сега имаме всички парчета, просто ги свързваме:

  1. Четеният поток - PDF файлът, изпраща към Bufferer
  2. Bufferer завършва и извиква нашия метод за parseAndLog за целия работник

Целият този процес е обвит в обещание, което само по себе си се връща към функцията за намаляване, в която седи вътре. Когато разреши, операцията за намаляване продължава.

Тази асинхронна опашка всъщност е наистина полезен модел, така че ще я разгледам по-подробно в следващата си публикация, която вероятно ще бъде с по-голямо ухапване от последните няколко.

Както и да е, останалата част от кода просто завършва процеса въз основа на обработка на грешки. Отново, ако това беше производствен код, можете да се обзаложите, че тук ще има по-стабилна регистрация и работа с грешки, но като доказателство за концепцията това изглежда добре.

Така че работи, но полезно ли е?

Значи там го имате. Това беше малко пътуване и със сигурност работи, но като всеки код е важно да прегледаме какви са неговите силни и слаби страни. От върха на главата ми:

  • Потоците трябва да се събират в буфери. Това, за съжаление, побеждава целта на използването на потоци и съответно ефективността на паметта страда. Това е необходима канала за работа с модула pdfreader. Бих искал да видя дали има начин за поточно предаване на PDF данни и да ги анализирате на по-фино ниво. Особено ако към него все още може да се приложи модулна, функционална логика за разбор.
  • В този етап на бебето логиката на анализа също е дразнещо крехка. Само помислете, ами ако имам документ, който е по-дълъг от страница? Един куп предположения излитат през прозореца и правят необходимостта от поточно предаване на PDF данни още по-силна.
  • И накрая, ще бъде чудесно да видим как бихме могли да изградим тази функционалност с логване и крайни точки на API, които да предоставим на обществеността - за цена или про bono, в зависимост от контекста, в който се използва.

Ако имате някакви конкретни критики или притеснения, аз също бих искал да ги чуя, тъй като забелязването на слабости в кода е първата стъпка за отстраняването им. И ако знаете какъвто и да е по-добър метод за едновременно предаване и анализиране на PDF файлове, уведомете ме, за да мога да го оставя тук за всеки, който чете тази публикация за отговор. Така или иначе - или за каквато и да е друга цел - изпратете ми имейл или се свържете с Reddit.