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)
|
|||
|
|
}
|
|||
|
|
}
|