chore: initial commit
This commit is contained in:
+417
@@ -0,0 +1,417 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user