Files
node_server/utils/logger.ts
T
2026-10-22 17:07:08 +08:00

418 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
});
}
}