365 lines
17 KiB
JavaScript
365 lines
17 KiB
JavaScript
/*
|
||
Сервис интеграции ПП Парус 8 с WEB API
|
||
Модуль ядра: запись протокола работы в файл
|
||
*/
|
||
|
||
//----------------------
|
||
// Подключение библиотек
|
||
//----------------------
|
||
|
||
const fs = require("fs"); //Файловая система
|
||
const path = require("path"); //Пути файловой системы
|
||
const { validateObject, getNowString, makeErrorText } = require("./utils"); //Вспомогательные функции
|
||
const { ServerError } = require("./server_errors"); //Типовая ошибка
|
||
const {
|
||
SCONSOLE_LOG_COLOR_PATTERN_ERR,
|
||
SCONSOLE_LOG_COLOR_PATTERN_INF,
|
||
SERR_COMMON,
|
||
SLOG_PREFIX_INF,
|
||
SLOG_PREFIX_ERR,
|
||
SLOG_PREFIX_PROTOCOL_ERR
|
||
} = require("./constants"); //Общие константы
|
||
const prmsLogFileWriterSchema = require("../models/prms_log_file_writer"); //Схемы валидации параметров функций модуля
|
||
|
||
//--------------------------
|
||
// Глобальные идентификаторы
|
||
//--------------------------
|
||
|
||
//Таймаут ожидания опустошения очереди записи в файл при закрытии (мс)
|
||
const NLOG_FILE_CLOSE_TIMEOUT = 10000;
|
||
|
||
//Интервал повторного сообщения об ошибке записи в файл (мс)
|
||
const NLOG_FILE_ERROR_REPORT_INTERVAL = 10000;
|
||
|
||
//Интервал повторного открытия файла лога после ошибки (мс)
|
||
const NLOG_FILE_REOPEN_INTERVAL = 10000;
|
||
|
||
//Максимум строк в очереди записи (защита памяти при высокой нагрузке / медленном диске)
|
||
const NLOG_FILE_MAX_PENDING = 2000;
|
||
|
||
//------------
|
||
// Тело модуля
|
||
//------------
|
||
|
||
//Дополнение числа ведущим нулём до двух знаков
|
||
const pad2 = nVal => String(nVal).padStart(2, "0");
|
||
|
||
//Формирование имени файла лога: log_yyyymmdd_hh24miss.log
|
||
const buildLogFileName = () => {
|
||
const dNow = new Date();
|
||
const sDate = `${dNow.getFullYear()}${pad2(dNow.getMonth() + 1)}${pad2(dNow.getDate())}`;
|
||
const sTime = `${pad2(dNow.getHours())}${pad2(dNow.getMinutes())}${pad2(dNow.getSeconds())}`;
|
||
return `log_${sDate}_${sTime}.log`;
|
||
};
|
||
|
||
//Проверка, что каталог существует и доступен для записи
|
||
const checkLogDirWritable = sPath => {
|
||
try {
|
||
//Каталог должен существовать
|
||
if (!fs.existsSync(sPath) || !fs.statSync(sPath).isDirectory()) return false;
|
||
//Права на запись
|
||
fs.accessSync(sPath, fs.constants.W_OK);
|
||
//Пробная запись
|
||
const sProbe = path.join(sPath, `.exs_log_write_probe_${process.pid}`);
|
||
fs.writeFileSync(sProbe, "");
|
||
fs.unlinkSync(sProbe);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
//Признак, что поток записи в файл непригоден
|
||
const isLogStreamBroken = stream => !stream || stream.destroyed || !stream.writable;
|
||
|
||
//Гарантия перевода строки в конце текста
|
||
const ensureLogLineEnd = sLine => (sLine.endsWith("\n") ? sLine : `${sLine}\n`);
|
||
|
||
//Безопасное уничтожение потока записи (removeAllListeners + destroy)
|
||
const destroyLogStream = stream => {
|
||
if (!stream) return null;
|
||
try {
|
||
stream.removeAllListeners("error");
|
||
if (!stream.destroyed) stream.destroy();
|
||
return null;
|
||
} catch (e) {
|
||
return e;
|
||
}
|
||
};
|
||
|
||
//Класс записи протокола в файл
|
||
class LogFileWriter {
|
||
//Конструктор класса
|
||
constructor() {
|
||
this.logStream = null;
|
||
this.sLogFile = null;
|
||
this.bFileLog = false;
|
||
this.bClosing = false;
|
||
this.writeChain = Promise.resolve();
|
||
this.nQueued = 0;
|
||
this.nLastFileWriteErrorAt = 0;
|
||
this.nLastLogFileReopenAt = 0;
|
||
this.sPendingLogLine = null;
|
||
}
|
||
//Признак активного протоколирования в файл
|
||
isEnabled() {
|
||
return this.bFileLog === true;
|
||
}
|
||
//Сброс состояния писателя (без уничтожения потока)
|
||
clearState() {
|
||
this.bFileLog = false;
|
||
this.bClosing = false;
|
||
this.logStream = null;
|
||
this.sLogFile = null;
|
||
this.writeChain = Promise.resolve();
|
||
this.nQueued = 0;
|
||
this.nLastFileWriteErrorAt = 0;
|
||
this.nLastLogFileReopenAt = 0;
|
||
this.sPendingLogLine = null;
|
||
}
|
||
//Создание потока записи в файл
|
||
createWriteStream(sFilePath) {
|
||
const stream = fs.createWriteStream(sFilePath, { flags: "a", encoding: "utf8" });
|
||
stream.on("error", e => this.handleWriteError(e));
|
||
return stream;
|
||
}
|
||
//Уничтожение текущего потока записи без сброса пути файла
|
||
destroyStream() {
|
||
destroyLogStream(this.logStream);
|
||
this.logStream = null;
|
||
}
|
||
//Уведомление в консоль о протоколировании в файл (без записи в файл)
|
||
notifyOpenedConsole() {
|
||
if (!this.isEnabled() || !this.sLogFile) return;
|
||
const sNow = getNowString();
|
||
console.log(SCONSOLE_LOG_COLOR_PATTERN_INF, `${sNow} ${SLOG_PREFIX_INF}: `, `Протоколирование в файл: ${this.sLogFile}`);
|
||
}
|
||
//Уведомление в консоль о результате закрытия файла лога (без записи в файл)
|
||
notifyClosedConsole(sLogFile, e) {
|
||
const sNow = getNowString();
|
||
if (e) {
|
||
console.log(
|
||
SCONSOLE_LOG_COLOR_PATTERN_ERR,
|
||
`${sNow} ${SLOG_PREFIX_ERR}: `,
|
||
`Ошибка закрытия файла лога${sLogFile ? ` (${sLogFile})` : ""}: ${makeErrorText(e)}`
|
||
);
|
||
return;
|
||
}
|
||
if (!sLogFile) return;
|
||
console.log(SCONSOLE_LOG_COLOR_PATTERN_INF, `${sNow} ${SLOG_PREFIX_INF}: `, `Файл лога закрыт: ${sLogFile}`);
|
||
}
|
||
//Сообщение об ошибке записи в файл с ограничением частоты
|
||
reportWriteError(e) {
|
||
const nNow = Date.now();
|
||
//Ограничиваем спам повторных сообщений
|
||
if (nNow - this.nLastFileWriteErrorAt < NLOG_FILE_ERROR_REPORT_INTERVAL) return;
|
||
this.nLastFileWriteErrorAt = nNow;
|
||
const sNow = getNowString();
|
||
console.log(SCONSOLE_LOG_COLOR_PATTERN_ERR, `${sNow} ${SLOG_PREFIX_PROTOCOL_ERR} В ФАЙЛ: `, makeErrorText(e));
|
||
}
|
||
//Обработка ошибки записи в файл лога
|
||
handleWriteError(e, sFailedLine) {
|
||
//Если протоколирование в файл не активно - выходим
|
||
if (!this.bFileLog && !this.sLogFile) return;
|
||
//Сообщим в консоль (с ограничением спама)
|
||
this.reportWriteError(e);
|
||
//Запомним неудачную строку для однократного повтора после переоткрытия
|
||
if (sFailedLine) this.sPendingLogLine = sFailedLine;
|
||
//При закрытии не пытаемся восстановить поток - close/release завершат освобождение
|
||
if (this.bClosing) return;
|
||
//Повтор только после переоткрытия непригодного потока (без цикла на "живом" потоке)
|
||
if (isLogStreamBroken(this.logStream)) {
|
||
if (this.reopen()) this.retryPendingLine();
|
||
}
|
||
}
|
||
//Открытие файла лога
|
||
open(sLogPath) {
|
||
//Если путь не задан - протоколирование в файл не ведётся
|
||
if (!sLogPath || !String(sLogPath).trim()) return;
|
||
//Нормализуем путь
|
||
const sPath = path.resolve(String(sLogPath).trim());
|
||
//Проверяем параметры
|
||
let sCheckResult = validateObject({ sLogPath: sPath }, prmsLogFileWriterSchema.open, "Параметры функции открытия файла лога");
|
||
if (sCheckResult) throw new ServerError(SERR_COMMON, sCheckResult);
|
||
//Каталог должен существовать и быть доступен для записи
|
||
if (!checkLogDirWritable(sPath))
|
||
throw new ServerError(SERR_COMMON, `Каталог размещения логов не существует или недоступен для записи: ${sPath}`);
|
||
//Если уже открыт - сначала освободим без сообщения в console
|
||
if (this.logStream || this.bFileLog) this.release(undefined, true);
|
||
//Формируем полный путь файла сессии
|
||
const sFullPath = path.join(sPath, buildLogFileName());
|
||
//Открываем поток записи
|
||
this.logStream = this.createWriteStream(sFullPath);
|
||
this.sLogFile = sFullPath;
|
||
this.bFileLog = true;
|
||
}
|
||
//Повторное открытие текущего файла лога
|
||
reopen() {
|
||
//Нет пути файла или идёт закрытие - не переоткрываем
|
||
if (!this.sLogFile || this.bClosing) return false;
|
||
//Ограничиваем частоту переоткрытия
|
||
const nNow = Date.now();
|
||
if (this.nLastLogFileReopenAt > 0 && nNow - this.nLastLogFileReopenAt < NLOG_FILE_REOPEN_INTERVAL) return false;
|
||
this.nLastLogFileReopenAt = nNow;
|
||
try {
|
||
//Освободим прежний поток, если он ещё есть
|
||
this.destroyStream();
|
||
//Откроем тот же файл на дозапись
|
||
this.logStream = this.createWriteStream(this.sLogFile);
|
||
this.bFileLog = true;
|
||
return true;
|
||
} catch (e) {
|
||
this.logStream = null;
|
||
this.reportWriteError(e);
|
||
return false;
|
||
}
|
||
}
|
||
//Освобождение ресурсов файла лога
|
||
release(eNotify, bSilentNotify) {
|
||
const stream = this.logStream;
|
||
const sLogFile = this.sLogFile;
|
||
this.clearState();
|
||
const eClose = destroyLogStream(stream);
|
||
//Результат закрытия - только в console
|
||
if (!bSilentNotify && sLogFile) this.notifyClosedConsole(sLogFile, eNotify !== undefined ? eNotify : eClose);
|
||
}
|
||
//Запись одной строки в файл
|
||
writeLine(sData) {
|
||
return new Promise((resolve, reject) => {
|
||
const stream = this.logStream;
|
||
//Уже поставленные в очередь строки дописываем даже при bClosing (close ждёт writeChain)
|
||
if (isLogStreamBroken(stream) || !this.bFileLog) {
|
||
resolve();
|
||
return;
|
||
}
|
||
//Пишем строку целиком
|
||
stream.write(sData, err => {
|
||
if (err) reject(err);
|
||
else resolve();
|
||
});
|
||
});
|
||
}
|
||
//Постановка записи в сериализованную очередь (с ограничением глубины)
|
||
scheduleWrite(sData) {
|
||
//Защита от разрастания очереди при отставании диска
|
||
if (this.nQueued >= NLOG_FILE_MAX_PENDING) {
|
||
this.reportWriteError(new Error(`Переполнена очередь записи в файл лога (лимит ${NLOG_FILE_MAX_PENDING}), строка пропущена`));
|
||
return;
|
||
}
|
||
this.nQueued++;
|
||
this.writeChain = this.writeChain
|
||
.then(() => this.writeLine(sData))
|
||
.catch(e => this.handleWriteError(e, sData))
|
||
.finally(() => {
|
||
if (this.nQueued > 0) this.nQueued--;
|
||
});
|
||
}
|
||
//Повторная запись строки, не попавшей в файл из-за ошибки
|
||
retryPendingLine() {
|
||
//Нечего повторять или нельзя писать
|
||
if (!this.sPendingLogLine || this.bClosing || isLogStreamBroken(this.logStream)) return;
|
||
const sData = this.sPendingLogLine;
|
||
this.sPendingLogLine = null;
|
||
this.scheduleWrite(sData);
|
||
}
|
||
//Постановка строки в очередь записи в файл (без ожидания записи вызывающей стороной)
|
||
enqueue(sLine) {
|
||
//Проверяем параметры
|
||
if (typeof sLine !== "string") return;
|
||
//Если файл не активен или идёт закрытие - ничего не делаем
|
||
if (!this.bFileLog || this.bClosing) return;
|
||
//Если поток потерян - попробуем переоткрыть
|
||
if (isLogStreamBroken(this.logStream)) {
|
||
if (!this.reopen()) return;
|
||
}
|
||
//Однократный повтор отложенной строки (если была ошибка записи)
|
||
this.retryPendingLine();
|
||
if (isLogStreamBroken(this.logStream)) return;
|
||
//Гарантируем перевод строки и ставим в очередь
|
||
this.scheduleWrite(ensureLogLineEnd(sLine));
|
||
}
|
||
//Синхронная дозапись строки в файл (авария / process.on("exit"))
|
||
appendSync(sLine) {
|
||
if (!this.sLogFile || typeof sLine !== "string") return;
|
||
try {
|
||
if (this.logStream) {
|
||
this.destroyStream();
|
||
this.bFileLog = false;
|
||
}
|
||
fs.appendFileSync(this.sLogFile, ensureLogLineEnd(sLine), "utf8");
|
||
} catch (e) {
|
||
this.reportWriteError(e);
|
||
}
|
||
}
|
||
//Корректное закрытие файла лога
|
||
async close() {
|
||
//Если файла нет - нечего закрывать
|
||
if (!this.logStream && !this.bFileLog && !this.sLogFile) return;
|
||
//Фиксируем режим закрытия (новые строки и reopen не принимаем)
|
||
this.bClosing = true;
|
||
this.sPendingLogLine = null;
|
||
const sLogFile = this.sLogFile;
|
||
let nDrainTimer = null;
|
||
try {
|
||
//Дождёмся опустошения очереди записи (с ограничением по времени)
|
||
const bDrained = await Promise.race([
|
||
this.writeChain.then(() => true).catch(() => true),
|
||
new Promise(resolve => {
|
||
nDrainTimer = setTimeout(() => resolve(false), NLOG_FILE_CLOSE_TIMEOUT);
|
||
})
|
||
]);
|
||
if (nDrainTimer) {
|
||
clearTimeout(nDrainTimer);
|
||
nDrainTimer = null;
|
||
}
|
||
//Очередь не успела опустеть - принудительно освободим файл
|
||
if (!bDrained) {
|
||
this.reportWriteError(
|
||
new Error(`Очередь записи в файл лога не опустела за ${NLOG_FILE_CLOSE_TIMEOUT} мс, файл будет закрыт принудительно`)
|
||
);
|
||
this.release();
|
||
return;
|
||
}
|
||
//Закроем поток штатно
|
||
await new Promise((resolve, reject) => {
|
||
if (!this.logStream || this.logStream.destroyed) {
|
||
resolve();
|
||
return;
|
||
}
|
||
this.logStream.end(err => {
|
||
if (err) reject(err);
|
||
else resolve();
|
||
});
|
||
});
|
||
} catch (e) {
|
||
//Только сообщение - без reopen при закрытии
|
||
this.reportWriteError(e);
|
||
this.release(e);
|
||
return;
|
||
} finally {
|
||
if (nDrainTimer) clearTimeout(nDrainTimer);
|
||
}
|
||
//Освобождаем состояние без повторного destroy уже закрытого потока
|
||
if (this.logStream) {
|
||
try {
|
||
this.logStream.removeAllListeners("error");
|
||
} catch {}
|
||
}
|
||
this.clearState();
|
||
//Результат закрытия - только в console
|
||
this.notifyClosedConsole(sLogFile, null);
|
||
}
|
||
//Аварийное синхронное освобождение файла лога
|
||
closeSync() {
|
||
if (!this.logStream && !this.bFileLog && !this.sLogFile) return;
|
||
this.bClosing = true;
|
||
this.release();
|
||
}
|
||
}
|
||
|
||
//-----------------
|
||
// Интерфейс модуля
|
||
//-----------------
|
||
|
||
exports.LogFileWriter = LogFileWriter;
|