Swift - 複数の出力先に対応したロガーの実装チュートリアル

# tl;dr

- ログとしての必要な情報を盛り込みつつ、出力先の追加・変更をしやすい設計を目指す
- 出力先を表す AppLogDestination とロガーを表す AppLogger をそれぞれ実装する
- まずはコンソールに出力する AppLogDestination を実装する
- 次に Firebase Crashlytics に出力する AppLogDestination を実装して追加する
- 最後にリモートサーバー（ログ集約サーバー）に出力する AppLogDestination を実装して追加する

# ロガーの使い方

まず、最終的な完成イメージを紹介。

- `AppLogger.shared.log` でログを出力できる。

```swift
AppLogger.shared.log("TEST", .debug)
AppLogger.shared.log("TEST", .info)
AppLogger.shared.log("TEST", .warn)
AppLogger.shared.log("TEST", .crit)
```

- 上記をコンソールに出力した場合のログは次の通り。ログに含める情報は [Developing a Tiny Logger in Swift – Sauvik Dolui – Medium](https://medium.com/@sauvik_dolui/developing-a-tiny-logger-in-swift-7221751628e6) を参考にする。

```
2018-08-08 07:46:08.379 [💬][AppDelegate.swift]:31 29 application(_:didFinishLaunchingWithOptions:) -> TEST
2018-08-08 07:46:08.426 [ℹ️][AppDelegate.swift]:32 29 application(_:didFinishLaunchingWithOptions:) -> TEST
2018-08-08 07:46:08.427 [⚠️][AppDelegate.swift]:33 29 application(_:didFinishLaunchingWithOptions:) -> TEST
2018-08-08 07:46:08.427 [🔥][AppDelegate.swift]:34 29 application(_:didFinishLaunchingWithOptions:) -> TEST
```

- 設計上目指すのは、新しい出力先を追加しやすくすること。具体的には、出力先を追加する時のコード差分が `+` だけになるようにする。

ここからは、実装するためのコードを順にみていく。

# Step1. ログレベルを定義する

ログレベルの文字列表現は絵文字にすると視認性が良いのでおすすめ。

```swift
/// LogLevel はログレベルを表す。
enum LogLevel: Int {
    case debug
    case info
    case warn
    case crit

    var string: String {
        switch self {
        case .debug:
            return "[💬]"
        case .info:
            return "[ℹ️]"
        case .warn:
            return "[⚠️]"
        case .crit:
            return "[🔥]"
        }
    }
}
```

# Step2. ログの出力先を定義する

AppLogDestination では無視するべきログレベルを考慮したり、ログ発生時の時間生成、ログ発生時のファイル名の整形などログ出力時の共通処理をプロトコルエクステンションで実装している。個別のログ出力先を追加する際には、このプロトコルを実装することになる。

```swift
/// AppLogDestination はログの出力先が実装すべきインターフェースを表す。
protocol AppLogDestination {
    var ignoreUnderThisLevel: LogLevel { get }

    func log(
        time: String,
        message: String,
        level: LogLevel,
        fileName: String,
        line: Int,
        column: Int,
        funcName: String
    )
}

extension AppLogDestination {
    var dateFormatter: DateFormatter {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone.current
        return formatter
    }

    func log(
        message: String,
        level: LogLevel,
        filePath: String,
        line: Int,
        column: Int,
        funcName: String
    ) {
        if level.rawValue >= ignoreUnderThisLevel.rawValue {
            log(time: dateFormatter.string(from: Date()),
                message: message,
                level: level,
                fileName: Self.sourceFileName(filePath: filePath),
                line: line,
                column: column,
                funcName: funcName
            )
        }
    }

    static func sourceFileName(filePath: String) -> String {
        let components = filePath.components(separatedBy: "/")
        return components.isEmpty ? "" : components.last!
    }
}
```

# Step3. ロガーを実装する

AppLogger は log() が呼ばれたら、自身が保持する AppLogDestinations すべての log() を呼び出す。

- `#file` は log() を呼び出した場所のファイル名になる
- `#line` は log() を呼び出した場所の行数になる
- `#column` は log() を呼び出した場所の列数になる
- `#function` は log() を呼び出した場所の関数名になる

この時点では AppLogDestination 実装を渡していないので log() は何も行わない。

```swift
/// AppLogger はアプリケーションのロガーを実装する
final class AppLogger {
    /// shared はシングルトンオブジェクトを表す
    static let shared = AppLogger([
    ])

    private let logDestinations: [AppLogDestination]
    private init(_ logDestinations: [AppLogDestination]) {
        self.logDestinations = logDestinations
    }

    /// log はロギングを行う
    func log(
        _ message: String,
        _ level: LogLevel,
        filePath: String = #file,
        line: Int = #line,
        column: Int = #column,
        funcName: String = #function
    ) {
        for dst in logDestinations {
            dst.log(
                message: message,
                level: level,
                filePath: filePath,
                line: line,
                column: column,
                funcName: funcName
            )
        }
    }
}
```

# Step4. コンソールに出力するロガーを実装する

AppLogConsoleDestination.log() は直列に print を呼ぶだけ。

```swift
/// AppLogConsoleDestination はコンソールにログを書き出す実装を表す。
struct AppLogConsoleDestination: AppLogDestination {
    private let serialLogQueue: DispatchQueue = DispatchQueue(
        label: "yourapp.domain.AppLogConsoleDestination"
    )
    let ignoreUnderThisLevel: LogLevel

    init(_ ignoreUnderThisLevel: LogLevel) {
        self.ignoreUnderThisLevel = ignoreUnderThisLevel
    }

    func log(
        time: String,
        message: String,
        level: LogLevel,
        fileName: String,
        line: Int,
        column: Int,
        funcName: String
    ) {
        serialLogQueue.async {
            print(
                "\(time) \(level.string)[\(fileName)]:\(line) \(column) \(funcName) -> \(message)"
            )
        }
    }
}
```

AppLogger に AppLogConsoleDestination を渡せば、コンソールに出力するようになる。出力する最低のログレベルは、デバッグフラグなどを使ってビルドごとに調整すると良い。

```diff
    /// shared はシングルトンオブジェクトを表す
    static let shared = AppLogger([
+       AppLogConsoleDestination(.debug),
    ])
```

ここまでで、コンソールに出力するロガーは完成した。

ここからは、既存のコードに影響を与えずに、ログの出力先を増やせることを確認していく。

# Step5. Firebase Crashlytics に出力するロガーを追加する

Firebase Crashlytics は iOS アプリのクラッシュログ収集ツールのデファクトだと思う。Crashlytics では、クラッシュにつながったイベントのコンテキストをさらに詳細に把握するために、カスタム Crashlytics ログをアプリから記録できるようになっている。

- [Firebase Crashlytics のクラッシュ レポートをカスタマイズする  |  Firebase](https://firebase.google.com/docs/crashlytics/customize-crash-reports?hl=ja#add_custom_logs)

Crashlytis へのログ送信を LogLevel warn 以上のログについて行いたい場合、専用の AppLogDestination を実装して AppLogger に追加することで実現できる。AppLogger の利用側に手を入れる必要はない。

```swift
struct AppLogCrashlyticsDestination: AppLogDestination {
    let ignoreUnderThisLevel: LogLevel

    init(_ ignoreUnderThisLevel: LogLevel) {
        self.ignoreUnderThisLevel = ignoreUnderThisLevel
    }

    func log(
        time: String,
        message: String,
        level: LogLevel,
        fileName: String,
        line: Int,
        column: Int,
        funcName: String
    ) {
        CLSLogv("%@ %@[%@]: %d %d %@ -> %@", getVaList([
            time,
            level.string,
            fileName,
            line,
            column,
            funcName,
            message,
        ]))
    }
}
```

```diff
    /// shared はシングルトンオブジェクトを表す
    static let shared = AppLogger([
        AppLogConsoleDestination(.debug),
+       AppLogCrashlyticsDestination(.warn),
    ])
```

# Step6. リモートサーバーに出力するロガーを実装する

クライアントログはサーバーに送信するようにしたほうがアプリケーションの問題に早期に気づくことができるので望ましい。柔軟なアラートの実現や SQL によるログの調査を行いたい場合には、自前のログ集約サーバーに送るのが現時点では良いと思う。

ここでも、専用の AppLogDestination を実装して AppLogger に追加すれば良い。

- サーバーに送る場合はログのバッファリング・リトライを考慮する必要があるので、ここでは自作の [BufferedLogger: Tiny but thread-safe logger with a buffering and retrying mechanism for iOS](https://github.com/yoheimuta/BufferedLogger) を使っている。デフォルトではログが 5 件溜まるか、10 秒経つと APIServerWriter.write() が呼ばれるので、その中でログをサーバーに送ればいい。

```swift
import BufferedLogger

struct AppLogAPIServerDestination: AppLogDestination {
    let ignoreUnderThisLevel: LogLevel
    private let logger: BFLogger

    init(_ ignoreUnderThisLevel: LogLevel,
         logRepository: IosapplogAPIRepository) {
        self.ignoreUnderThisLevel = ignoreUnderThisLevel
        logger = BFLogger(writer: APIServerWriter(logRepository, consoleLogger))
    }

    func log(
        time: String,
        message: String,
        level: LogLevel,
        fileName: String,
        line: Int,
        column: Int,
        funcName: String
    ) {
        let log = Iosapplogpb_Log(logID: UUID().uuidString,
                                  time: time,
                                  message: message,
                                  level: level,
                                  fileName: fileName,
                                  line: line,
                                  column: column,
                                  funcName: funcName)
        let payload = try! log.serializedData()
        logger.post(payload)
    }
}

private class APIServerWriter: Writer {
    private let disposeBag = DisposeBag()
    private let logRepository: IosapplogAPIRepository

    init(_ logRepository: IosapplogAPIRepository) {
        self.logRepository = logRepository
    }

    func write(_ chunk: Chunk, completion: @escaping (Bool) -> Void) {
        let logs = chunk.entries.map { entry -> Iosapplogpb_Log in
            try! Iosapplogpb_Log(serializedData: entry.payload)
        }

        logRepository.saveLogs(Iosapplogpb_SaveLogsRequest(logs: logs))
            .do(onNext: { _ in
                completion(true)
            }, onError: { error in
                completion(false)
            })
            .subscribe()
            .disposed(by: disposeBag)
    }
}
```

```diff
    /// shared はシングルトンオブジェクトを表す
    static let shared = AppLogger([
        AppLogConsoleDestination(.debug),
        AppLogCrashlyticsDestination(.warn),
+       AppLogAPIServerDestination(.info, logRepository: IosapplogService.shared),
    ])
```