log プロパティ追加でカスタムログ先を注入可能に
Hotwire Native iOS に log プロパティが追加され、debugLoggingEnabled が有効なときに任意の Logger 実装へデバッグログを流せるようになりました。これにより、テストや外部ロギングシステムへの統合がシンプルに実現します。
背景
デフォルトのロガーは OSLog に固定されていた ため、テスト時のログ捕捉や自前のロギングフレームワークへの委譲が面倒でした。クライアント側から出力先を差し替える要求が出てきたものの、既存 API を壊さずに拡張する方法が求められていました。本 PR はその要望に応える形で、設定だけでカスタムロガーを注入できるインターフェースを提供します。
技術的な変更
HotwireConfig.swift に log プロパティが追加されました。log は Logger? 型でデフォルトは nil、debugLoggingEnabled の didSet と同様に HotwireLogger.update(debugLoggingEnabled:log:) を呼び出し、設定変更を即時反映します。
public struct HotwireConfig {
/// Enables detailed debug logging.
public var debugLoggingEnabled = false {
didSet {
HotwireLogger.update(debugLoggingEnabled: debugLoggingEnabled, log: log)
}
}
/// Inject a custom logger. Used only when `debugLoggingEnabled` is true.
public var log: Logger? = nil {
didSet {
HotwireLogger.update(debugLoggingEnabled: debugLoggingEnabled, log: log)
}
}
// … other properties omitted …
}
Source/Logging/HotwireLogger.swift は新規実装され、デバッグ有効性と注入ロガーに応じて内部 logger 変数を切り替えます。デフォルトは OSLog ラッパー、無効時は常に disabledLogger が使用されます。
import Foundation
enum HotwireLogger {
static let enabledLogger = OSLogWrapper(subsystem: Bundle.module.bundleIdentifier!, category: "HotwireNative")
static let disabledLogger = OSLogWrapper.disabled
static func update(debugLoggingEnabled: Bool, log: Logger?) {
if debugLoggingEnabled {
logger = log ?? enabledLogger
} else {
logger = disabledLogger
}
}
}
var logger: Logger = HotwireLogger.disabledLogger
ロガー抽象化の核となる Logger プロトコルが Source/Logging/Logger.swift に導入され、log(message:level:file:function:line:) と、レベル別のデフォルト実装を提供します。これにより、任意のロギング実装が Logger に準拠すれば差し替え可能です。
import Foundation
public protocol Logger {
func log(message: String, level: LogLevel, file: String, function: String, line: UInt)
func debug(_ message: String, file: String, function: String, line: UInt)
func info(_ message: String, file: String, function: String, line: UInt)
func warning(_ message: String, file: String, function: String, line: UInt)
func error(_ message: String, file: String, function: String, line: UInt)
}
extension Logger {
public func debug(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
log(message: message, level: .debug, file: file, function: function, line: line)
}
public func info(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
log(message: message, level: .info, file: file, function: function, line: line)
}
public func warning(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
log(message: message, level: .warning, file: file, function: function, line: line)
}
public func error(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
log(message: message, level: .error, file: file, function: function, line: line)
}
}
LogLevel 列挙体が Source/Logging/LogLevel.swift に追加され、debug, info, warning, error の4 つのレベルを定義します。OSLog へのマッピングは OSLogWrapper の内部拡張で行われます。
public enum LogLevel {
case debug
case info
case warning
case error
}
OSLogWrapper が Source/Logging/OSLogWrapper.swift に実装され、Logger に準拠したデフォルトロガーとして機能します。disabled 静的プロパティは OSLog の無効化ロガーを生成し、HotwireLogger.disabledLogger として再利用されます。
import OSLog
public struct OSLogWrapper: Logger {
private let osLogger: os.Logger
public init(subsystem: String, category: String) {
self.osLogger = os.Logger(subsystem: subsystem, category: category)
}
public static var disabled: Logger {
OSLogWrapper(osLogger: os.Logger(.disabled))
}
public func log(message: String, level: LogLevel, file: String, function: String, line: UInt) {
osLogger.log(level: level.osLogLevel, "\(message)")
}
private init(osLogger: os.Logger) {
self.osLogger = osLogger
}
}
private extension LogLevel {
var osLogLevel: OSLogType {
switch self {
case .debug: return .debug
case .info: return .info
case .warning: return .error
case .error: return .error
}
}
}
テストスイートが拡充され、CustomLoggerTests.swift でカスタムロガーが期待通りに動作すること、セッターの順序に依存しないこと、デバッグ無効時はロガーが呼ばれないことが検証されています。
import XCTest
@testable import HotwireNative
final class CustomLoggerTests: XCTestCase {
private var spy: LoggerSpy!
override func setUp() {
super.setUp()
spy = LoggerSpy()
resetLoggingConfig()
}
override func tearDown() {
resetLoggingConfig()
spy = nil
super.tearDown()
}
func test_customLogger_receivesMessages_whenDebugLoggingEnabled() {
Hotwire.config.debugLoggingEnabled = true
Hotwire.config.log = spy
logger.debug("debug message")
logger.info("info message")
logger.warning("warning message")
logger.error("error message")
XCTAssertEqual(spy.messages(for: .debug), ["debug message"])
XCTAssertEqual(spy.messages(for: .info), ["info message"])
XCTAssertEqual(spy.messages(for: .warning), ["warning message"])
XCTAssertEqual(spy.messages(for: .error), ["error message"])
}
func test_customLogger_isWired_regardlessOfSetterOrder() {
Hotwire.config.log = spy
Hotwire.config.debugLoggingEnabled = true
logger.debug("message")
XCTAssertEqual(spy.messages(for: .debug), ["message"])
}
func test_customLogger_isNotUsed_whenDebugLoggingDisabled() {
Hotwire.config.debugLoggingEnabled = false
Hotwire.config.log = spy
logger.debug("debug message")
logger.error("error message")
XCTAssertTrue(spy.allMessages.isEmpty)
}
}
設計判断
プロトコルベースの抽象化により、デフォルトは OSLogWrapper、任意の実装は Logger に準拠すれば差し替え可能という拡張性が確保されました。debugLoggingEnabled が false のときは常に disabledLogger が使われ、無駄な I/O を防いでパフォーマンスへの影響を最小限に抑えています。API 変更は log プロパティの追加のみで済み、既存コードへの破壊的影響はなく後方互換性が保たれています。
まとめ
本 PR は log プロパティの追加により、デバッグログの出力先をアプリ側で自由に指定できるようにしました。デフォルトの OSLog を維持しつつ、プロトコルベースの設計でテスト容易性と外部ロギングシステムとの統合をシンプルに実現しています。debugLoggingEnabled が有効なときだけカスタムロガーが有効になるため、既存挙動とのスムーズな共存が保証されています。