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