418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { WriteStream } from "fs";
|
||
import * as fs from "node:fs";
|
||
import * as path from "node:path";
|
||
import { randomUUID } from "node:crypto";
|
||
import {log, font_style} from "../types/utils/logger";
|
||
|
||
export interface LogHistoryRecord {
|
||
id: string;
|
||
timestamp: number;
|
||
log: log[];
|
||
}
|
||
|
||
export interface _Logger {
|
||
info(msg: string): void;
|
||
success(msg: string): void;
|
||
error(msg: string): void;
|
||
warning(msg: string): void;
|
||
tip(msg: string): void;
|
||
custom(msg: string, font_color: string, font_style: font_style[]): void;
|
||
log(log: log[]): void;
|
||
get_recent_log(count: number): log[][];
|
||
read_recent_log(count: number): Promise<LogHistoryRecord[]>;
|
||
close(): Promise<void>;
|
||
}
|
||
|
||
export class Logger implements _Logger {
|
||
// 单个日志文件的最大字节数(50M)
|
||
private static readonly MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||
// 同一目录下保留的最大日志文件数
|
||
private static readonly MAX_FILES = 10;
|
||
// 日志文件名格式:year-month-day_log.log / year-month-day_log_{index}.log
|
||
private static readonly LOG_FILE_REGEX = /^\d{4}-\d{2}-\d{2}_log(_\d+)?\.log$/;
|
||
// 重建写入流的最大失败次数与重试延时
|
||
private static readonly MAX_RECREATE_FAILURES = 10;
|
||
private static readonly RECREATE_DELAY = 1000;
|
||
|
||
private readonly log_dir: string;
|
||
private writer!: WriteStream;
|
||
// 当前日志所属日期(year-month-day)
|
||
private day = '';
|
||
// 当天的文件序号,0 为当天首个文件
|
||
private index = 0;
|
||
private size = 0;
|
||
private write_queue: string[] = [];
|
||
private in_flight_chunk: string | undefined;
|
||
private writing = false;
|
||
private recreate_failures = 0;
|
||
private recreate_timer: NodeJS.Timeout | null = null;
|
||
private stopped = false;
|
||
private close_task: Promise<void> | undefined;
|
||
constructor(log_dir: string) {
|
||
this.log_dir = log_dir;
|
||
this.day = this.format_day(new Date());
|
||
this.open_writer();
|
||
}
|
||
|
||
private bind_writer_error(): void {
|
||
this.writer.on('error', (err: NodeJS.ErrnoException) => {
|
||
this.writing = false;
|
||
// 仅处理文件不存在,重建写入流;其他错误交由后续写入重试
|
||
if (err.code === 'ENOENT') {
|
||
this.schedule_recreate();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 合并并发错误,避免同一时刻重复创建
|
||
private schedule_recreate(): void {
|
||
if (this.stopped || this.recreate_timer !== null) {
|
||
return;
|
||
}
|
||
this.recreate_timer = setTimeout(() => {
|
||
this.recreate_timer = null;
|
||
this.recreate_writer();
|
||
}, Logger.RECREATE_DELAY);
|
||
this.recreate_timer.unref();
|
||
}
|
||
|
||
// 重新创建写入流;累计失败达到上限后停止创建
|
||
private recreate_writer(): void {
|
||
if (this.stopped) {
|
||
return;
|
||
}
|
||
this.recreate_failures++;
|
||
if (this.recreate_failures >= Logger.MAX_RECREATE_FAILURES) {
|
||
this.stopped = true;
|
||
return;
|
||
}
|
||
try {
|
||
this.open_writer();
|
||
this.recreate_failures = 0;
|
||
this.flush();
|
||
} catch {
|
||
// 创建失败,排到下一次重试
|
||
this.schedule_recreate();
|
||
}
|
||
}
|
||
|
||
private format_day(date: Date): string {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
// 当天首个文件为 year-month-day_log.log,超出大小后追加序号
|
||
private build_filename(day: string, index: number): string {
|
||
return index === 0 ? `${day}_log.log` : `${day}_log_${index}.log`;
|
||
}
|
||
|
||
// 打开(或创建)当前应写入的文件,并接管已有大小
|
||
private open_writer(): void {
|
||
if (!fs.existsSync(this.log_dir)) {
|
||
fs.mkdirSync(this.log_dir, { recursive: true });
|
||
}
|
||
const filename = this.build_filename(this.day, this.index);
|
||
const file_path = path.join(this.log_dir, filename);
|
||
try {
|
||
this.size = fs.statSync(file_path).size;
|
||
} catch {
|
||
this.size = 0;
|
||
}
|
||
const broken = this.writer;
|
||
if (broken) {
|
||
try {
|
||
broken.destroy();
|
||
} catch {
|
||
// 忽略已销毁流抛出的异常
|
||
}
|
||
}
|
||
this.writer = fs.createWriteStream(file_path, {flags: 'a'});
|
||
this.bind_writer_error();
|
||
this.prune_files(filename);
|
||
}
|
||
|
||
// 超出保留数量时删除最旧的日志文件(文件名按时间递增,保留当前文件)
|
||
private prune_files(keep_filename: string): void {
|
||
let entries: string[];
|
||
try {
|
||
entries = fs.readdirSync(this.log_dir)
|
||
.filter(name => Logger.LOG_FILE_REGEX.test(name))
|
||
.sort();
|
||
} catch {
|
||
return;
|
||
}
|
||
let excess = entries.length - Logger.MAX_FILES;
|
||
for (const name of entries) {
|
||
if (excess <= 0) break;
|
||
if (name === keep_filename) continue;
|
||
try {
|
||
fs.rmSync(path.join(this.log_dir, name));
|
||
excess--;
|
||
} catch {
|
||
// 删除失败则跳过,保留文件
|
||
}
|
||
}
|
||
}
|
||
|
||
// 跨天或写满时切换到新文件
|
||
private ensure_writer(): void {
|
||
const today = this.format_day(new Date());
|
||
if (today !== this.day) {
|
||
this.day = today;
|
||
this.index = 0;
|
||
this.open_writer();
|
||
return;
|
||
}
|
||
if (this.size >= Logger.MAX_FILE_SIZE) {
|
||
this.index++;
|
||
this.open_writer();
|
||
}
|
||
}
|
||
|
||
public info(message: any): log { // 白色
|
||
return {
|
||
message,
|
||
font_color: '#ffffff',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public success(message: any): log { // 柔和绿
|
||
return {
|
||
message,
|
||
font_color: '#8fb98f',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public warning(message: any): log { // 柔和沙黄
|
||
return {
|
||
message,
|
||
font_color: '#d1b478',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public error(message: any): log { // 柔和玫红
|
||
return {
|
||
message,
|
||
font_color: '#cf8e8e',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public tip(message: any): log { // 柔和钢蓝
|
||
return {
|
||
message,
|
||
font_color: '#8fb0c9',
|
||
font_style: ['normal']
|
||
};
|
||
}
|
||
|
||
public custom(message: any, font_color: string, font_style: font_style[]): log {
|
||
return {
|
||
message,
|
||
font_color,
|
||
font_style
|
||
};
|
||
}
|
||
|
||
public log(log: log[]): void {
|
||
if (this.stopped || log.length === 0) {
|
||
return;
|
||
}
|
||
const data = {
|
||
id: randomUUID(),
|
||
timestamp: Date.now(),
|
||
log
|
||
}
|
||
this.write_queue.push(`${JSON.stringify(data)}\n`);
|
||
this.flush();
|
||
}
|
||
|
||
// 串行写入:上一条落盘后才写下一批,保证大量日志的顺序
|
||
private flush(): void {
|
||
if (this.writing || this.stopped) {
|
||
return;
|
||
}
|
||
const chunk = this.write_queue.shift();
|
||
if (chunk === undefined) {
|
||
return;
|
||
}
|
||
this.ensure_writer();
|
||
this.writing = true;
|
||
this.in_flight_chunk = chunk;
|
||
this.writer.write(chunk, (err) => {
|
||
if (this.in_flight_chunk === chunk) this.in_flight_chunk = undefined;
|
||
this.writing = false;
|
||
if (err) {
|
||
if (this.stopped) return;
|
||
// 写入失败则回队,等待重建写入流后重试,避免丢日志
|
||
this.write_queue.unshift(chunk);
|
||
return;
|
||
}
|
||
this.size += Buffer.byteLength(chunk);
|
||
this.flush();
|
||
});
|
||
}
|
||
|
||
public close(): Promise<void> {
|
||
if (this.close_task) return this.close_task;
|
||
this.stopped = true;
|
||
if (this.recreate_timer) clearTimeout(this.recreate_timer);
|
||
this.recreate_timer = null;
|
||
this.close_task = new Promise<void>(resolve => {
|
||
if (this.writer.closed) {
|
||
resolve();
|
||
return;
|
||
}
|
||
this.writer.once('close', () => {
|
||
this.write_queue.length = 0;
|
||
resolve();
|
||
});
|
||
this.writer.end(this.write_queue.join(''));
|
||
});
|
||
return this.close_task;
|
||
}
|
||
|
||
private static parse_history_record(line: string): LogHistoryRecord | undefined {
|
||
try {
|
||
const record = JSON.parse(line);
|
||
if (record && typeof record.id === 'string' &&
|
||
typeof record.timestamp === 'number' && Number.isFinite(record.timestamp) &&
|
||
Array.isArray(record.log)) {
|
||
return record;
|
||
}
|
||
} catch {
|
||
// Ignore corrupt or incomplete records.
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
public static async read_recent_log(log_dir: string, count: number): Promise<LogHistoryRecord[]> {
|
||
if (!Number.isFinite(count) || count < 1) return [];
|
||
count = Math.floor(count);
|
||
let names: string[];
|
||
try {
|
||
names = (await fs.promises.readdir(log_dir))
|
||
.filter(name => Logger.LOG_FILE_REGEX.test(name))
|
||
.sort((a, b) => {
|
||
const date_order = a.slice(0, 10).localeCompare(b.slice(0, 10));
|
||
return date_order || Number(a.match(/_(\d+)\.log$/)?.[1] ?? 0) -
|
||
Number(b.match(/_(\d+)\.log$/)?.[1] ?? 0);
|
||
}).reverse();
|
||
} catch {
|
||
return [];
|
||
}
|
||
const records: LogHistoryRecord[] = [];
|
||
const max_line_bytes = 1024 * 1024;
|
||
let budget = 16 * max_line_bytes;
|
||
for (const name of names) {
|
||
if (records.length >= count || budget <= 0) break;
|
||
let file: fs.promises.FileHandle | undefined;
|
||
try {
|
||
file = await fs.promises.open(path.join(log_dir, name), 'r');
|
||
let position = (await file.stat()).size;
|
||
let pending = Buffer.alloc(0);
|
||
let skipping = false;
|
||
const accept = (line: Buffer) => {
|
||
if (line.length > max_line_bytes) return;
|
||
const record = Logger.parse_history_record(line.toString('utf8'));
|
||
if (record) records.push(record);
|
||
};
|
||
while (position > 0 && records.length < count && budget > 0) {
|
||
const length = Math.min(position, 64 * 1024, budget);
|
||
position -= length;
|
||
budget -= length;
|
||
const chunk = Buffer.allocUnsafe(length);
|
||
const {bytesRead} = await file.read(chunk, 0, length, position);
|
||
const data = Buffer.concat([chunk.subarray(0, bytesRead), pending]);
|
||
let end = data.length;
|
||
let newline: number;
|
||
while ((newline = data.lastIndexOf(10, end - 1)) >= 0 && end > 0) {
|
||
if (!skipping) accept(data.subarray(newline + 1, end));
|
||
skipping = false;
|
||
end = newline;
|
||
if (records.length >= count) break;
|
||
}
|
||
// Discard oversized lines until their preceding newline is found.
|
||
if (skipping || end > max_line_bytes) {
|
||
pending = Buffer.alloc(0);
|
||
skipping = true;
|
||
} else {
|
||
pending = Buffer.from(data.subarray(0, end));
|
||
}
|
||
}
|
||
if (position === 0 && !skipping && records.length < count) accept(pending);
|
||
} catch {
|
||
// Rotation or removal can race a history read; continue with older files.
|
||
} finally {
|
||
if (file) await file.close();
|
||
}
|
||
}
|
||
return records.reverse();
|
||
}
|
||
|
||
public async read_recent_log(count: number): Promise<LogHistoryRecord[]> {
|
||
if (!Number.isFinite(count) || count < 1) return [];
|
||
count = Math.floor(count);
|
||
const snapshot = [this.in_flight_chunk, ...this.write_queue]
|
||
.filter((chunk): chunk is string => chunk !== undefined)
|
||
.map(chunk => Logger.parse_history_record(chunk))
|
||
.filter((record): record is LogHistoryRecord => record !== undefined);
|
||
const snapshot_ids = new Set(snapshot.map(record => record.id));
|
||
const records = (await Logger.read_recent_log(this.log_dir, count))
|
||
.filter(record => !snapshot_ids.has(record.id));
|
||
const ids = new Set(records.map(record => record.id));
|
||
for (const record of snapshot) {
|
||
if (ids.has(record.id)) continue;
|
||
ids.add(record.id);
|
||
records.push(record);
|
||
}
|
||
return records.slice(-count);
|
||
}
|
||
|
||
// 读取最近的 count 条日志,跨文件时自动按时间顺序拼接
|
||
public get_recent_log(count: number): log[][] {
|
||
if (count <= 0) {
|
||
return [];
|
||
}
|
||
let names: string[];
|
||
try {
|
||
names = fs.readdirSync(this.log_dir)
|
||
.filter(name => Logger.LOG_FILE_REGEX.test(name))
|
||
.sort()
|
||
.reverse();
|
||
} catch {
|
||
return [];
|
||
}
|
||
const lines: string[] = [];
|
||
for (const name of names) {
|
||
let content: string;
|
||
try {
|
||
content = fs.readFileSync(path.join(this.log_dir, name), 'utf-8');
|
||
} catch {
|
||
continue;
|
||
}
|
||
const file_lines = content.split('\n').filter(line => line.length > 0);
|
||
for (let i = file_lines.length - 1; i >= 0 && lines.length < count; i--) {
|
||
lines.push(file_lines[i]);
|
||
}
|
||
if (lines.length >= count) {
|
||
break;
|
||
}
|
||
}
|
||
// 收集顺序为从新到旧,反转恢复为时间正序
|
||
lines.reverse();
|
||
return lines.map(line => {
|
||
try {
|
||
return JSON.parse(line);
|
||
} catch {
|
||
return line;
|
||
}
|
||
});
|
||
}
|
||
}
|