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