Как проанализировать несколько больших XML-файлов с хорошей производительностью и балансом использования памяти?

Я реализую программу, которая должна получать файлы от пользователя в Angular, отправлять их в серверную часть node.js, где они будут прочитаны и преобразованы в массив объектов.

Я выбрал JavaScript, потому что для меня это был самый быстрый способ реализовать эту программу и протестировать ее.

Если у вас есть какие-либо другие советы по языку, я учту их в своей следующей реализации.

На данный момент мои XML-файлы имеют самый большой размер 110 МБ и самый маленький размер 16 МБ.

Мне нужно иметь возможность анализировать до 3000 файлов, размеры которых могут различаться.

Все эти файлы имеют одинаковую структуру и теги почти в одном и том же порядке.

Пример XML:

<start_xml_tag>
   <property1>something</property1>
   .
   .
       <property10>somethingElse</property10>
  <Offerte>
   <codice>123</codice>
   <nome>andrea</nome>
   <stato>italia</stato>
  </Offerte>
  ... 1Milion row after
  <Offerte>
   <codice>123</codice>
   <nome>andrea</nome>
   <stato>italia</stato>
  </Offerte>
  ...2 milion row after
</start_xml_tag>

Результатом обработки является массив, содержащий объекты, где каждый объект выглядит следующим образом:

{ код: 123, Имя: Андреа Стато: Италия }

Заранее пользователь может вставить фильтр, где, если поле stato равно (например) 'italia', результатом в объектах должны быть только те, у которых есть stato = italia.

Я предоставлю свой код, как только смогу, меня не будет дома до завтра, поэтому у меня его здесь нет.

Можете ли вы мне помочь или сказать, где я ошибаюсь?

Заранее спасибо!

До сих пор мне удавалось добиться этого, загружая файлы по тексту с меньшим количеством файлов, до 400 или даже 600.

Затем я переключился на использование потокового саксофона для чтения файлов по частям, но завершение синтаксического анализа занимало целую вечность, я не уверен, допустил ли я какие-то ошибки, он анализировал все хорошо, но через 9 часов он все еще работал.

Размер фрагмента был установлен на 100 000 байт, выше этого значения я получал ошибку максимального размера стека.

Сейчас я пытаюсь с помощью подхода xpath посмотреть, смогу ли я нацелиться на всех детей «Offerte» с помощью выражения, но я новичок в этом и все еще думаю, что буду вообще не слишком эффективен.

import { Injectable } from '@angular/core';
import { HttpClient,  } from '@angular/common/http';

import * as sax from 'sax';
import { targetTags, TargetTags, startTagName, checkUnitReferenceNo, checkStatusCd } from 'src/models/util';

const BATCH_SIZE = 100;

@Injectable({
  providedIn: 'root'
})
export class BatchXmlService {

  constructor(private http: HttpClient) { }

  private result: TargetTags[] = [];

  async processFiles(files: FileList, unitReferenceNoFilter: any, statusCdFilters: any) {
    let result = [];
    for (let i = 0; i < files.length; i += BATCH_SIZE) {
      const batch = Array.from(files).slice(i, i + BATCH_SIZE);
      result.push(...await this.processBatch(batch, unitReferenceNoFilter, statusCdFilters));
    }
    return result;
  }

  async processBatch(batch: File[], unitReferenceNoFilter: any, statusCdFilters: any) {
    const promises = batch.map(file => this.parseFile(file, unitReferenceNoFilter, statusCdFilters));
    const parsedData = await Promise.all(promises);
    return parsedData.flatMap(x=>x);
  }

  // Function to parse a single XML file
  async parseFile(file: File, unitReferenceNoFilter: any, statusCdFilters: any): Promise<any[]> {
    const strict = true; 

    return new Promise((resolve, reject) => {
      const saxStream = sax.createStream(strict);

      saxStream.on('error', (error) => {
        reject(error);
      });

      const parsedData: any[] = []; // Array to store parsed data
      let lastTag = "";
      let inTargetSection = false; // Flag to track if we're within the target section
      let skipParsing = false;

      saxStream.on('opentag', (node) => {
        if (node.name === startTagName) {
          inTargetSection = true;
          parsedData.push({});
        } 
        else if (inTargetSection && !skipParsing) {
          lastTag = node.name;
          if (targetTags.includes(node.name)){
            // Store tag name as key and content as value in the current offer
            const currentOffer = parsedData[parsedData.length - 1];
            if (currentOffer) {
              currentOffer[node.name] = "";
            }
          }
        }
      });

      saxStream.on('text', (text) => {
        // Assuming you only care about text content within target tags
        if (text.trim() === "") return;
        if (inTargetSection && lastTag?.length > 0 && targetTags.includes(lastTag) && !skipParsing) {
          const currentOffer = parsedData[parsedData.length - 1];
          if (lastTag){
            if (
              lastTag === "UNIT_REFERENCE_NO" && !checkUnitReferenceNo(unitReferenceNoFilter, text.trim()) ||
              lastTag === "STATUS_CD" && !checkStatusCd(statusCdFilters, text.trim())
            ){
              skipParsing = true;
              parsedData.pop();
              return;
            }

            currentOffer[lastTag] = text.trim();
          }
        }
      });

      saxStream.on('closetag', (nodeName) => {
        if (nodeName === startTagName) {
          inTargetSection = false;
          skipParsing = false;
        }
      });

      saxStream.on('end', () => {
        resolve(parsedData);
      });

      const reader = new FileReader();
      reader.readAsArrayBuffer(file);

      reader.onload = () => {
        const arrayBuffer = reader.result as ArrayBuffer;
        const byteArray = new Uint8Array(arrayBuffer); // Convert to Uint8Array

        let remainingData = byteArray;

        while (remainingData.length > 0) {
          const chunkSize = Math.min(remainingData.length, 100000); // Read in chunks of 50000 bytes
          saxStream.write(String.fromCharCode.apply(null, remainingData.slice(0, chunkSize)));
          remainingData = remainingData.slice(chunkSize);
        }

        saxStream.end(); // Call end after processing all data
      };

      reader.onerror = (error) => {
        reject(error); // Handle file read errors
      };
    });
  }
}
<form [formGroup] = "form" class = "d-flex flex-column justify-content-around w-100" (ngSubmit) = "generateExcel()" style = "height: 300px">

    <div class = "d-flex flex-column">
        <label for = "Filtro_UNIT_REFERENCE_NO">Filtro per UNIT_REFERENCE_NO <sup style = "color: red">*</sup></label>
        <div class = "d-flex justify-content-between w-50">
            <select class = "form-control" formControlName = "UNIT_REFERENCE_NO" (change) = "setUnitReferenceNoFilter()">
                <option *ngFor = "let item of unitReferenceNoOptions" [value] = "item.value">{{item.label}}</option>
            </select>
        </div>
    </div>

    <div class = "d-flex flex-column">
        <label for = "Filtro_STATUS_CD">Filtro per STATUS_CD</label>
        <div class = "d-flex justify-content-between w-50">
            <ng-select [items] = "statusCdOptions" class = "w-100"
                [multiple] = "true"
                placeholder = "Se nessuna scelta e' selezionata allora si estraggono tutte"
                formControlName = "STATUS_CD"
                bindLabel = "label" 
                bindValue = "value">
            </ng-select>
        </div>
    </div>

    <input #myInput for = "files-xml" (click) = "checkFiltersInserted($event)" formControlName = "files" class = "form-control mx-1" type = "file" class = "file-upload bg-secondary-subtle w-50" (change) = "onUpload($event)" required webkitdirectory multiple />

    <button class = "btn btn-primary w-25" type = "submit" [disabled] = "!isUnitReferenceNoFiltered || (loadingData && result !== [])">
        <span *ngIf = "loadingData" class = "spinner-border spinner-border-sm" role = "status" aria-hidden = "true"></span> 
        Genera Excel
    </button>
</form>

И моя функция app.ts onUpload такова:

async onUpload(event: any) {
const files = event.target.files;
this.loadingData = true;
try{
  this.result = await this.batchXmlService.processFiles(files, this.form.controls['UNIT_REFERENCE_NO'].value, this.form.controls['STATUS_CD'].value);
  this.loadingData = false;
}
catch(error){
  this.loadingData = false;
}

}

Я постарался очистить код как можно лучше, у меня было еще несколько закомментированных кодов из предыдущих тестов.

Я знаю, что это происходит на Angular.js, а не на Node.js. Сейчас я пытаюсь написать ту же логику в node.js, чтобы делегировать эти вычисления на серверную часть.

Я также забыл, что второй фильтр представляет собой массив вариантов, где в предыдущем примере XML поле «nome» могло иметь разные значения, все из которых содержатся в списке вариантов множественного выбора.

Этот фильтр работает следующим образом: Если в списке есть ['Андреа', 'Франческо', 'Брайан']

Он фильтрует объект так, чтобы все результаты соответствовали одной из этих трех строк.

Accepted XML example:
<Offerte>
   <codice>123</codice>
   <nome>andrea</nome>
   <stato>italia</stato>
</Offerte>

Discarded xml example:
<Offerte>
   <codice>123</codice>
   <nome>john</nome>
   <stato>italia</stato>
</Offerte>

🤔 А знаете ли вы, что...
JavaScript может выполняться как на стороне клиента (в браузере), так и на стороне сервера (с использованием Node.js).


91
2

Ответы:

Попробуйте выполнить сценарий PS

using assembly System.Xml.Linq

$xmlFilename = 'c:\temp\test.xml'
$reader = [System.Xml.XmlReader]::Create($xmlFilename)
$table = [System.Collections.ArrayList]::new()

While(-not $reader.EOF)
{
    if ($reader.Name -ne 'Offerte')
    {
        $reader.ReadToFollowing('Offerte') | out-null;
    }
    if (-not $reader.EOF)
    {
        $offerte = [System.Xml.Linq.XElement][System.Xml.Linq.XElement]::ReadFrom($reader);
        $codice = $offerte.Element('codice').Value
        $nome = $offerte.Element('nome').Value
        $stato = $offerte.Element('stato').Value

       $newRow = [pscustomobject]@{
           codice = $codice
           nome = $nome
           stato = $stato
       }
       $table.Add($newRow) | out-null  
    }
}
$table

Решено

Мне удалось решить свою проблему, следуя тому, что предложил jdweng с помощью его ps-скрипта, а затем я вернулся к своему первому решению синтаксического анализа, которое похоже на его.

Сначала я решил получить файлы из папки прямо из node.js, а не переносить их с фронтенда на бэкенд, потому что упаковывать и отправлять их будет очень долго.

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

Итак, для тех, кто борется с чем-то похожим на мой случай, я сделал это:

Достаём файлы из папки и начинаем читать их по одному:

async function getExcelRows(filterNome, filterStato){
        let result = [];
    
        const fileNames = await readFolder();
    
        for(let fileName of fileNames){
            const response = await readAndParseFile(folderPath+fileName, filterNome, filterStato);
    
            result.push(...response);
        }
        return JSON.stringify(result);
    }

Реализация для чтения папки:

async function readFolder(){
    const files = await fsp.readdir(folderPath);
    return files.filter(file => file.endsWith('.xml'));
}

Реализация чтения файлов частями:

async function readAndParseFile(filePath, filterNome, filterStato){

    return new Promise((resolve, reject) => {
        let keeper = {};
        let isToErase = true;
        let result = [];

        const re = new RegExp("<"+objTag+">", 'g');

        const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });

        stream.on('data', async (chunk) => {
            
            if (isToErase){
                keeper = {};
                keeper.remain = chunk;
            }
            else{
                if (keeper.remain.indexOf("<"+objTag+">") != -1)
                    testFileOfferteNumber--;
                keeper.remain +=chunk;
            }

            testFileOfferteNumber += keeper.remain.match(re).length;

            if (keeper.remain.indexOf("<"+objTag+">") !== -1){
                keeper = await parseFile(keeper.remain, filterNome, filterStato);
                if (keeper?.result.length > 0){
                    result.push(...keeper.result);
                    keeper.result = [];
                }
                if (keeper?.remain != "")
                    isToErase = false;
                else
                    isToErase = true;
            }
        });

        stream.on('end', () => {
            resolve(result);
        });

        stream.on('error', (error) => {
            reject(error);
        });
    });
}

Переменная testFileOfferteNumber должна была проверять, действительно ли я обрабатывал все теги без ошибок, не более того.

Итак, здесь мы читаем файл частями, и если после анализа у меня все еще остается неполный контент, я возвращаю его обратно в эту функцию и устанавливаю для isToErase значение false, если это так, чтобы следующий фрагмент был добавлен к моему текущему оставшемуся фрагменту. содержание. В противном случае я просто беру новый фрагмент и обрабатываю его.

Реализация логики синтаксического анализа:

// objTag = "Offerte"
// filterNome = ["andrea"]
// filterStato = "italia"
function parseFile(source, filterNome, filterStato){
    return new Promise((resolve, reject)=>{
        try{
            const endTagLen = ("</"+objTag+">").length;
            let result = [];
            while(source.indexOf("<"+objTag+">") !== -1){

                if (source.indexOf("</"+objTag+">") === -1)
                    return resolve({result: result, remain: source});

                testResultOfferteNumber++;

                if (
                    !checkNomeFilter(filterNome, checkField(source, 'nome') ? getField(source, "nome") : "") ||
                    !checkStato(filterStato, checkField(source, 'stato') ? getField(source, "stato") : "")
                ){
                    source = source.slice(source.indexOf("</"+objTag+">")+endTagLen);
                    continue;
                }
                
                const obj = {
                    codice: checkField(source, 'codice') ? getField(source, "codice") : "",
                    nome: checkField(source, 'nome') ? getField(source, "nome") : "",
                    stato: checkField(source, 'stato') ? getField(source, "stato") : "",
                }

                source = source.slice(source.indexOf("</"+objTag+">")+endTagLen);

                result.push(obj);
            }
            resolve({result: result, remain: source});
        }
        catch(e){
            reject(e);
        }
    });
}

testResultOfferteNumber — это переменная, которую нужно сравнить с предыдущей, чтобы проверить, правильно ли обработаны все теги.

Итак, здесь я перебираю все теги «Offerte», пока они есть, и в этом «пока» я проверяю, есть ли закрывающий тег, если нет, я возвращаю все, что осталось, в функцию чтения, которая добавит следующий фрагмент текста к этому тексту, при этом он сможет впоследствии проверить это содержимое.

После этого я применяю свои фильтры, чтобы избежать анализа данных, которые не принимаются до их анализа, а затем, поскольку я знаю структуру xml, я просто извлекаю все 3 тега, которые мне нужны, одновременно.

После этого анализа я отсекаю уже разобранную часть файла и перезапускаю цикл с новым содержимым в <Offerte>...</Offerte>.

Я оставил размер фрагмента по умолчанию, который у меня отлично работал: 3000 файлов были обработаны примерно за 20-30 минут без сбоев.

В моем случае мне нужно было вернуть результат в виде строки, чтобы я мог создать файл Excel.

Я надеюсь, что это решение поможет кому-то, как я, спасибо всем, кто помог мне с этой проблемой, особенно jdweng.