загрузка каталога ftp вызывает ошибку превышения максимального стека вызовов - PullRequest
0 голосов
/ 04 ноября 2019

В настоящее время я работаю над сценарием резервного копирования с NodeJS. Скрипт рекурсивно загружает каталог и его файлы и подкаталоги с использованием FTP / FTPS. Я использую пакет basic-ftp для выполнения вызовов FTP.

Когда я пытаюсь загрузить большой каталог с большим количеством подкаталогов, я получаю ошибку Maximum call stack size exceeded, ноЯ не понимаю, почему и где это происходит. Я не вижу петли бесконечности или пропущенных обратных вызовов. После нескольких часов отладки у меня больше нет идей.

Я не использую метод downloadDirTo из basic-ftp, потому что я не хочу останавливать загрузку после случайной ошибки. Когда возникает ошибка, она должна продолжаться и должна добавить ошибку в файл журнала.

Хранилище находится здесь: https://github.com/julianpoemp/webspace-backup.

Как только FTPManager готов, я вызываюМетод doBackup (см. метод в BackupManager). Этот метод вызывает метод downloadFolder, определенный в FTPManager.

export class BackupManager {

    private ftpManager: FtpManager;

    constructor() {
        osLocale().then((locale) => {
            ConsoleOutput.info(`locale is ${locale}`);
            moment.locale(locale);
        }).catch((error) => {
            ConsoleOutput.error(error);
        });

        this.ftpManager = new FtpManager(AppSettings.settings.backup.root, {
            host: AppSettings.settings.server.host,
            port: AppSettings.settings.server.port,
            user: AppSettings.settings.server.user,
            password: AppSettings.settings.server.password,
            pasvTimeout: AppSettings.settings.server.pasvTimeout
        });

        this.ftpManager.afterManagerIsReady().then(() => {
            this.doBackup();
        }).catch((error) => {
            ConsoleOutput.error(error);
        });
    }

    public doBackup() {
        let errors = '';
        if (fs.existsSync(path.join(AppSettings.appPath, 'errors.log'))) {
            fs.unlinkSync(path.join(AppSettings.appPath, 'errors.log'));
        }
        if (fs.existsSync(path.join(AppSettings.appPath, 'statistics.txt'))) {
            fs.unlinkSync(path.join(AppSettings.appPath, 'statistics.txt'));
        }
        const subscr = this.ftpManager.error.subscribe((message: string) => {
            ConsoleOutput.error(`${moment().format('L LTS')}: ${message}`);
            const line = `${moment().format('L LTS')}:\t${message}\n`;
            errors += line;
            fs.appendFile(path.join(AppSettings.appPath, 'errors.log'), line, {
                encoding: 'Utf8'
            }, () => {
            });
        });

        let name = AppSettings.settings.backup.root.substring(0, AppSettings.settings.backup.root.lastIndexOf('/'));
        name = name.substring(name.lastIndexOf('/') + 1);
        const downloadPath = (AppSettings.settings.backup.downloadPath === '') ? AppSettings.appPath : AppSettings.settings.backup.downloadPath;

        ConsoleOutput.info(`Remote path: ${AppSettings.settings.backup.root}\nDownload path: ${downloadPath}\n`);

        this.ftpManager.statistics.started = Date.now();
        this.ftpManager.downloadFolder(AppSettings.settings.backup.root, path.join(downloadPath, name)).then(() => {
            this.ftpManager.statistics.ended = Date.now();
            this.ftpManager.statistics.duration = (this.ftpManager.statistics.ended - this.ftpManager.statistics.started) / 1000 / 60;

            ConsoleOutput.success('Backup finished!');
            const statistics = `\n-- Statistics: --
Started: ${moment(this.ftpManager.statistics.started).format('L LTS')}
Ended: ${moment(this.ftpManager.statistics.ended).format('L LTS')}
Duration: ${this.ftpManager.getTimeString(this.ftpManager.statistics.duration * 60 * 1000)} (H:m:s)

Folders: ${this.ftpManager.statistics.folders}
Files: ${this.ftpManager.statistics.files}
Errors: ${errors.split('\n').length - 1}`;

            ConsoleOutput.log('\n' + statistics);
            fs.writeFileSync(path.join(AppSettings.appPath, 'statistics.txt'), statistics, {
                encoding: 'utf-8'
            });
            if (errors !== '') {
                ConsoleOutput.error(`There are errors. Please read the errors.log file for further information.`);
            }
            subscr.unsubscribe();
            this.ftpManager.close();
        }).catch((error) => {
            ConsoleOutput.error(error);
            this.ftpManager.close();
        });
    }
}
import * as ftp from 'basic-ftp';
import {FileInfo} from 'basic-ftp';
import * as Path from 'path';
import * as fs from 'fs';
import {Subject} from 'rxjs';
import {FtpEntry, FTPFolder} from './ftp-entry';
import {ConsoleOutput} from './ConsoleOutput';
import moment = require('moment');

export class FtpManager {
    private isReady = false;
    private _client: ftp.Client;
    private currentDirectory = '';

    public readyChange: Subject<boolean>;
    public error: Subject<string>;
    private connectionOptions: FTPConnectionOptions;

    public statistics = {
        folders: 0,
        files: 0,
        started: 0,
        ended: 0,
        duration: 0
    };

    private recursives = 0;

    constructor(path: string, options: FTPConnectionOptions) {
        this._client = new ftp.Client();
        this._client.ftp.verbose = false;
        this.readyChange = new Subject<boolean>();
        this.error = new Subject<string>();
        this.currentDirectory = path;
        this.connectionOptions = options;


        this.connect().then(() => {
            this.isReady = true;
            this.gotTo(path).then(() => {
                this.onReady();
            }).catch((error) => {
                ConsoleOutput.error('ERROR: ' + error);
                this.onConnectionFailed();
            });
        });
    }

    private connect(): Promise<void> {
        return new Promise<void>((resolve, reject) => {
            this._client.access({
                host: this.connectionOptions.host,
                user: this.connectionOptions.user,
                password: this.connectionOptions.password,
                secure: true
            }).then(() => {
                resolve();
            }).catch((error) => {
                reject(error);
            });
        });
    }

    private onReady = () => {
        this.isReady = true;
        this.readyChange.next(true);
    };

    private onConnectionFailed() {
        this.isReady = false;
        this.readyChange.next(false);
    }

    public close() {
        this._client.close();
    }

    public async gotTo(path: string) {
        return new Promise<void>((resolve, reject) => {
            if (this.isReady) {
                ConsoleOutput.info(`open ${path}`);
                this._client.cd(path).then(() => {
                    this._client.pwd().then((dir) => {
                        this.currentDirectory = dir;
                        resolve();
                    }).catch((error) => {
                        reject(error);
                    });
                }).catch((error) => {
                    reject(error);
                });
            } else {
                reject(`FTPManager is not ready. gotTo ${path}`);
            }
        });
    }

    public async listEntries(path: string): Promise<FileInfo[]> {
        if (this.isReady) {
            return this._client.list(path);
        } else {
            throw new Error('FtpManager is not ready. list entries');
        }
    }

    public afterManagerIsReady(): Promise<void> {
        return new Promise<void>((resolve, reject) => {
            if (this.isReady) {
                resolve();
            } else {
                this.readyChange.subscribe(() => {
                        resolve();
                    },
                    (error) => {
                        reject(error);
                    },
                    () => {
                    });
            }
        });
    }

    public async downloadFolder(remotePath: string, downloadPath: string) {
        this.recursives++;

        if (this.recursives % 100 === 99) {
            ConsoleOutput.info('WAIT');
            await this.wait(0);
        }

        if (!fs.existsSync(downloadPath)) {
            fs.mkdirSync(downloadPath);
        }

        try {
            const list = await this.listEntries(remotePath);
            for (const fileInfo of list) {
                if (fileInfo.isDirectory) {
                    const folderPath = remotePath + fileInfo.name + '/';
                    try {
                        await this.downloadFolder(folderPath, Path.join(downloadPath, fileInfo.name));
                        this.statistics.folders++;
                        ConsoleOutput.success(`${this.getCurrentTimeString()}===> Directory downloaded: ${remotePath}\n`);
                    } catch (e) {
                        this.error.next(e);
                    }
                } else if (fileInfo.isFile) {
                    try {
                        const filePath = remotePath + fileInfo.name;
                        if (this.recursives % 100 === 99) {
                            ConsoleOutput.info('WAIT');
                            await this.wait(0);
                        }
                        await this.downloadFile(filePath, downloadPath, fileInfo);
                    } catch (e) {
                        this.error.next(e);
                    }
                }
            }
            return true;
        } catch (e) {
            this.error.next(e);
            return true;
        }
    }

    public async downloadFile(path: string, downloadPath: string, fileInfo: FileInfo) {
        this.recursives++;
        if (fs.existsSync(downloadPath)) {
            const handler = (info) => {
                let procent = Math.round((info.bytes / fileInfo.size) * 10000) / 100;
                if (isNaN(procent)) {
                    procent = 0;
                }
                let procentStr = '';
                if (procent < 10) {
                    procentStr = '__';
                } else if (procent < 100) {
                    procentStr = '_';
                }
                procentStr += procent.toFixed(2);

                ConsoleOutput.log(`${this.getCurrentTimeString()}---> ${info.type} (${procentStr}%): ${info.name}`);
            };

            if (this._client.closed) {
                try {
                    await this.connect();
                } catch (e) {
                    throw new Error(e);
                }
            }
            this._client.trackProgress(handler);
            try {
                await this._client.downloadTo(Path.join(downloadPath, fileInfo.name), path);
                this._client.trackProgress(undefined);
                this.statistics.files++;
                return true;
            } catch (e) {
                throw new Error(e);
            }
        } else {
            throw new Error('downloadPath does not exist');
        }
    }

    public chmod(path: string, permission: string): Promise<void> {
        return new Promise<void>((resolve, reject) => {
            this._client.send(`SITE CHMOD ${permission} ${path}`).then(() => {
                console.log(`changed chmod of ${path} to ${permission}`);
                resolve();
            }).catch((error) => {
                reject(error);
            });
        });
    }

    public getCurrentTimeString(): string {
        const duration = Date.now() - this.statistics.started;
        return moment().format('L LTS') + ' | Duration: ' + this.getTimeString(duration) + ' ';
    }

    public getTimeString(timespan: number) {
        if (timespan < 0) {
            timespan = 0;
        }

        let result = '';
        const minutes: string = this.formatNumber(this.getMinutes(timespan), 2);
        const seconds: string = this.formatNumber(this.getSeconds(timespan), 2);
        const hours: string = this.formatNumber(this.getHours(timespan), 2);

        result += hours + ':' + minutes + ':' + seconds;

        return result;
    }

    private formatNumber = (num, length): string => {
        let result = '' + num.toFixed(0);
        while (result.length < length) {
            result = '0' + result;
        }
        return result;
    };

    private getSeconds(timespan: number): number {
        return Math.floor(timespan / 1000) % 60;
    }

    private getMinutes(timespan: number): number {
        return Math.floor(timespan / 1000 / 60) % 60;
    }

    private getHours(timespan: number): number {
        return Math.floor(timespan / 1000 / 60 / 60);
    }

    public async wait(time: number): Promise<void> {
        return new Promise<void>((resolve) => {
            setTimeout(() => {
                resolve();
            }, time);
        });
    }
}


export interface FTPConnectionOptions {
    host: string;
    port: number;
    user: string;
    password: string;
    pasvTimeout: number;
}

Ответы [ 2 ]

1 голос
/ 07 ноября 2019

Я нашел источник проблемы. Это пакет pkg, который выдает ошибку превышения максимального стека вызовов: www.github.com / zeit / pkg / Issues / 681 .

Когда я тестирую его напрямую с помощью узла в Windows, он работает,Я либо понизюсь до 10, либо ищу другое решение.

Спасибо @blex за помощь!

1 голос
/ 06 ноября 2019

Проблема

Внутри функции FtpManager.downloadFolder я вижу рекурсивные вызовы того же метода downloadFolder с await. Оттуда может возникнуть ваша ошибка Maximum call stack exceeded, так как при первом вызове вам нужно будет сохранить все в памяти при обходе всех подкаталогов.

Предлагаемое решение

Вместо await рекурсивного всего, выможет настроить систему очередей, используя алгоритм, подобный следующему:

  • Добавить текущую папку в очередь
  • Пока эта очередь не пуста:
    • Получить первуюпапка в очереди (и удалить ее из нее)
    • Список всех записей в ней
    • Загрузить все файлы
    • Добавить все подпапки в очередь

Это позволяет загружать множество папок в цикле вместо использования рекурсии. Каждая итерация цикла будет выполняться независимо, это означает, что результат загрузки корневого каталога не будет зависеть от дерева файлов deeeeeep внутри него.

Использование администратора очередей

Существует множество администраторов очередеймодули для NodeJS, которые позволяют вам иметь параллелизм, тайм-ауты и т. д. Один из них, который я использовал в прошлом, просто называется queue . Он имеет много полезных функций, но потребует немного больше работы для реализации в вашем проекте. Следовательно, для этого ответа я не использовал модуль внешней очереди, чтобы вы могли видеть логику, стоящую за ним. Не стесняйтесь искать queue, job, concurrency ...

Пример

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

Примечание: Для простоты я не добавил никакой обработки ошибок, это простодоказательство концепции! Вы можете найти демонстрационный проект, который использует этот здесь, на моем Github .

Вот как я это сделал:

const fs = require('fs-extra');
const Path = require('path');

class CopyManager {
  constructor() {
    // Create a queue accessible by all methods
    this.folderQueue = [];
  }

  /**
   * Copies a directory
   * @param {String} remotePath
   * @param {String} downloadPath
   */
  async copyFolder(remotePath, downloadPath) {
    // Add the folder to the queue
    this.folderQueue.push({ remotePath, downloadPath });
    // While the queue contains folders to download
    while (this.folderQueue.length > 0) {
      // Download them
      const { remotePath, downloadPath } = this.folderQueue.shift();
      console.log(`Copy directory: ${remotePath} to ${downloadPath}`);
      await this._copyFolderAux(remotePath, downloadPath);
    }
  }

  /**
   * Private internal method which copies the files from a folder,
   * but if it finds subfolders, simply adds them to the folderQueue
   * @param {String} remotePath
   * @param {String} downloadPath
   */
  async _copyFolderAux(remotePath, downloadPath) {
    await fs.mkdir(downloadPath);
    const list = await this.listEntries(remotePath);
    for (const fileInfo of list) {
      if (fileInfo.isDirectory) {
        const folderPath = Path.join(remotePath, fileInfo.name);
        const targetPath = Path.join(downloadPath, fileInfo.name);
        // Push the folder to the queue
        this.folderQueue.push({ remotePath: folderPath, downloadPath: targetPath });
      } else if (fileInfo.isFile) {
        const filePath = Path.join(remotePath, fileInfo.name);
        await this.copyFile(filePath, downloadPath, fileInfo);
      }
    }
  }

  /**
   * Copies a file
   * @param {String} filePath
   * @param {String} downloadPath
   * @param {Object} fileInfo
   */
  async copyFile(filePath, downloadPath, fileInfo) {
    const targetPath = Path.join(downloadPath, fileInfo.name);
    console.log(`Copy file: ${filePath} to ${targetPath}`);
    return await fs.copy(filePath, targetPath);
  }

  /**
   * Lists entries from a folder
   * @param {String} remotePath
   */
  async listEntries(remotePath) {
    const fileNames = await fs.readdir(remotePath);
    return Promise.all(
      fileNames.map(async name => {
        const stats = await fs.lstat(Path.join(remotePath, name));
        return {
          name,
          isDirectory: stats.isDirectory(),
          isFile: stats.isFile()
        };
      })
    );
  }
}

module.exports = CopyManager;
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...