305 lines
11 KiB
Swift
305 lines
11 KiB
Swift
//
|
||
// FileLogHandler.swift
|
||
// Pods
|
||
//
|
||
// Created by 250102 on 2025/5/21.
|
||
//
|
||
|
||
|
||
import Foundation
|
||
import os.log
|
||
|
||
/// 一个rotatable、robust且efficient的文件日志处理器
|
||
public class FileLogHandler {
|
||
// MARK: - 日志级别枚举
|
||
|
||
public enum LogLevel: Int, Comparable {
|
||
case verbose = 0
|
||
case debug = 1
|
||
case info = 2
|
||
case warning = 3
|
||
case error = 4
|
||
case fatal = 5
|
||
|
||
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
|
||
return lhs.rawValue < rhs.rawValue
|
||
}
|
||
|
||
public var prefix: String {
|
||
switch self {
|
||
case .verbose: return "V"
|
||
case .debug: return "D"
|
||
case .info: return "I"
|
||
case .warning: return "W"
|
||
case .error: return "E"
|
||
case .fatal: return "WTF"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 旋转配置结构
|
||
|
||
public struct RotationConfig {
|
||
/// 日志文件旋转前的最大大小(字节)(默认:10MB)
|
||
public var maxFileSize: UInt64
|
||
|
||
/// 要保留的归档日志文件的最大数量(默认:5)
|
||
public var maxBackupCount: Int
|
||
|
||
public init(maxFileSize: UInt64 = 10_485_760, // 10MB
|
||
maxBackupCount: Int = 5) {
|
||
self.maxFileSize = maxFileSize
|
||
self.maxBackupCount = maxBackupCount
|
||
}
|
||
}
|
||
|
||
// MARK: - 属性
|
||
|
||
private let fileURL: URL
|
||
private let rotationConfig: RotationConfig
|
||
private let dateFormatter: DateFormatter
|
||
private let minimumLogLevel: LogLevel
|
||
private let queue: DispatchQueue
|
||
private let fileManager: FileManager
|
||
private var fileHandle: FileHandle?
|
||
|
||
// MARK: - 初始化
|
||
|
||
/// 初始化一个新的FileLogHandler
|
||
/// - 参数:
|
||
/// - directory: 日志文件的目录路径
|
||
/// - filename: 日志文件的基本名称(不带扩展名)
|
||
/// - rotationConfig: 日志rotation配置
|
||
/// - minimumLogLevel: 要记录的最小日志级别
|
||
public init(directory: String? = nil,
|
||
filename: String = "app.log",
|
||
rotationConfig: RotationConfig = RotationConfig(),
|
||
minimumLogLevel: LogLevel = .debug) {
|
||
self.rotationConfig = rotationConfig
|
||
self.minimumLogLevel = minimumLogLevel
|
||
|
||
// 创建时间戳的日期格式化器
|
||
self.dateFormatter = DateFormatter()
|
||
self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||
|
||
// 创建调度队列以确保线程安全
|
||
self.queue = DispatchQueue(label: "com.fileloghandler.queue", qos: .utility)
|
||
|
||
// 使用文件管理器
|
||
self.fileManager = FileManager.default
|
||
|
||
// 确定日志目录
|
||
let baseDirectory: URL
|
||
if let directory = directory {
|
||
baseDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("Logs", isDirectory: true).appendingPathComponent(directory, isDirectory: true)
|
||
} else {
|
||
// 如果未指定目录,默认为缓存目录
|
||
#if os(macOS)
|
||
let cacheDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||
#else
|
||
let cacheDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||
#endif
|
||
baseDirectory = cacheDir.appendingPathComponent("Logs", isDirectory: true)
|
||
}
|
||
|
||
// 如果日志目录不存在,则创建它
|
||
let logsDirectory = baseDirectory
|
||
try? fileManager.createDirectory(at: logsDirectory, withIntermediateDirectories: true, attributes: nil)
|
||
|
||
// 设置文件URL
|
||
self.fileURL = logsDirectory.appendingPathComponent(filename)
|
||
|
||
// 打开或创建日志文件
|
||
self.openLogFile()
|
||
}
|
||
|
||
deinit {
|
||
try? fileHandle?.close()
|
||
}
|
||
|
||
// MARK: - 公共方法
|
||
|
||
/// 使用指定级别记录消息
|
||
/// - 参数:
|
||
/// - message: 要记录的消息
|
||
/// - level: 日志的严重级别
|
||
/// - file: 调用日志的文件
|
||
/// - function: 调用日志的函数
|
||
/// - line: 调用日志的行
|
||
public func log(_ tag: String, _ message: String,
|
||
level: LogLevel = .info) {
|
||
guard level >= minimumLogLevel else { return }
|
||
|
||
queue.async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
// 格式化日志条目
|
||
let timestamp = self.dateFormatter.string(from: Date())
|
||
let pid = ProcessInfo.processInfo.processIdentifier
|
||
let logMessage = "\(timestamp) \(pid) \(level.prefix)/\(tag): \(message)\n"
|
||
|
||
// 写入日志文件
|
||
self.writeToFile(logMessage)
|
||
|
||
// 检查是否需要基于文件大小进行rotate
|
||
self.checkSizeRotation()
|
||
}
|
||
}
|
||
|
||
/// 手动触发日志rotate
|
||
public func rotateLogFile() {
|
||
queue.async { [weak self] in
|
||
self?.performRotation()
|
||
}
|
||
}
|
||
|
||
// MARK: - 私有方法
|
||
|
||
private func openLogFile() {
|
||
do {
|
||
// 如果文件不存在,则创建它
|
||
if !fileManager.fileExists(atPath: fileURL.path) {
|
||
try "".write(to: fileURL, atomically: true, encoding: .utf8)
|
||
}
|
||
|
||
// 打开文件以进行写入
|
||
fileHandle = try FileHandle(forWritingTo: fileURL)
|
||
|
||
// 移动到文件末尾
|
||
fileHandle?.seekToEndOfFile()
|
||
} catch {
|
||
print("打开日志文件时出错: \(error)")
|
||
}
|
||
}
|
||
|
||
private func writeToFile(_ message: String) {
|
||
guard let data = message.data(using: .utf8) else { return }
|
||
|
||
do {
|
||
if fileHandle == nil {
|
||
// 如果fileHandle为nil,尝试重新打开文件
|
||
openLogFile()
|
||
}
|
||
|
||
if #available(iOS 13.4, macOS 10.15.4, watchOS 6.4, tvOS 13.4, *) {
|
||
try fileHandle?.write(contentsOf: data)
|
||
} else {
|
||
fileHandle?.write(data)
|
||
}
|
||
} catch {
|
||
print("写入日志文件时出错: \(error)")
|
||
|
||
// 如果无法写入,关闭并尝试重新打开
|
||
try? fileHandle?.close()
|
||
fileHandle = nil
|
||
}
|
||
}
|
||
|
||
private func checkSizeRotation() {
|
||
do {
|
||
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
|
||
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||
|
||
if fileSize >= rotationConfig.maxFileSize {
|
||
performRotation()
|
||
}
|
||
} catch {
|
||
print("检查文件大小时出错: \(error)")
|
||
}
|
||
}
|
||
|
||
private func performRotation() {
|
||
do {
|
||
// 关闭当前文件句柄
|
||
try fileHandle?.close()
|
||
fileHandle = nil
|
||
|
||
// 获取日志目录
|
||
let directory = fileURL.deletingLastPathComponent()
|
||
let filename = fileURL.lastPathComponent
|
||
|
||
// 获取当前备份日志列表
|
||
let baseFilename = filename
|
||
let pattern = "^\(baseFilename)\\.(\\d+)$"
|
||
let regex = try NSRegularExpression(pattern: pattern, options: [])
|
||
|
||
let logFiles = try fileManager.contentsOfDirectory(atPath: directory.path)
|
||
.filter { filename in
|
||
let range = NSRange(location: 0, length: filename.utf16.count)
|
||
return regex.firstMatch(in: filename, options: [], range: range) != nil
|
||
}
|
||
.sorted { file1, file2 in
|
||
// 提取备份号并比较
|
||
let range1 = NSRange(location: 0, length: file1.utf16.count)
|
||
let range2 = NSRange(location: 0, length: file2.utf16.count)
|
||
|
||
guard let match1 = regex.firstMatch(in: file1, options: [], range: range1),
|
||
let match2 = regex.firstMatch(in: file2, options: [], range: range2) else {
|
||
return false
|
||
}
|
||
|
||
let numberRange1 = match1.range(at: 1)
|
||
let numberRange2 = match2.range(at: 1)
|
||
|
||
let number1 = strToInt(file1.utf16.dropFirst(baseFilename.utf16.count + 1).prefix(numberRange1.length)) ?? 0
|
||
let number2 = strToInt(file2.utf16.dropFirst(baseFilename.utf16.count + 1).prefix(numberRange2.length)) ?? 0
|
||
|
||
return number1 < number2
|
||
}
|
||
|
||
// 如果我们有太多备份日志,则删除旧的备份日志
|
||
if logFiles.count >= rotationConfig.maxBackupCount {
|
||
for i in (rotationConfig.maxBackupCount - 1)..<logFiles.count {
|
||
try fileManager.removeItem(at: directory.appendingPathComponent(logFiles[i]))
|
||
}
|
||
}
|
||
|
||
// 移动现有的备份日志
|
||
for i in (0..<logFiles.count).reversed() {
|
||
let file = logFiles[i]
|
||
let range = NSRange(location: 0, length: file.utf16.count)
|
||
guard let match = regex.firstMatch(in: file, options: [], range: range) else {
|
||
continue
|
||
}
|
||
|
||
let numberRange = match.range(at: 1)
|
||
guard let number = strToInt(file.utf16.dropFirst(baseFilename.utf16.count + 1).prefix(numberRange.length)) else {
|
||
continue
|
||
}
|
||
|
||
if number < rotationConfig.maxBackupCount - 1 {
|
||
let oldPath = directory.appendingPathComponent(file)
|
||
let newPath = directory.appendingPathComponent("\(baseFilename).\(number + 1)")
|
||
try fileManager.moveItem(at: oldPath, to: newPath)
|
||
} else {
|
||
try fileManager.removeItem(at: directory.appendingPathComponent(file))
|
||
}
|
||
}
|
||
|
||
// 重命名当前日志文件
|
||
let newBackupPath = directory.appendingPathComponent("\(baseFilename).1")
|
||
try fileManager.moveItem(at: fileURL, to: newBackupPath)
|
||
|
||
// 创建新的日志文件
|
||
openLogFile()
|
||
|
||
// 写入rotation消息
|
||
let timestamp = dateFormatter.string(from: Date())
|
||
let rotationMessage = "[\(timestamp)] [I] [FileLogHandler] Rotated\n"
|
||
writeToFile(rotationMessage)
|
||
|
||
} catch {
|
||
print("rotate日志文件时出错: \(error)")
|
||
|
||
// 即使rotate失败,也尝试重新打开文件
|
||
openLogFile()
|
||
}
|
||
}
|
||
private func strToInt(_ number: Substring.UTF16View) -> Int? {
|
||
guard let numStr = String(number) else {
|
||
return nil
|
||
}
|
||
return Int(numStr)
|
||
}
|
||
}
|