tl;dr
- ログとしての必要な情報を盛り込みつつ、出力先の追加・変更をしやすい設計を目指す
- 出力先を表す AppLogDestination とロガーを表す AppLogger をそれぞれ実装する
- まずはコンソールに出力する AppLogDestination を実装する
- 次に Firebase Crashlytics に出力する AppLogDestination を実装して追加する
- 最後にリモートサーバー(ログ集約サーバー)に出力する AppLogDestination を実装して追加する
ロガーの使い方
まず、最終的な完成イメージを紹介。
-
AppLogger.shared.log
でログを出力できる。
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 を参考にする。
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. ログレベルを定義する
ログレベルの文字列表現は絵文字にすると視認性が良いのでおすすめ。
/// 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 では無視するべきログレベルを考慮したり、ログ発生時の時間生成、ログ発生時のファイル名の整形などログ出力時の共通処理をプロトコルエクステンションで実装している。個別のログ出力先を追加する際には、このプロトコルを実装することになる。
/// 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() は何も行わない。
/// 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 を呼ぶだけ。
/// 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 を渡せば、コンソールに出力するようになる。出力する最低のログレベルは、デバッグフラグなどを使ってビルドごとに調整すると良い。
/// shared はシングルトンオブジェクトを表す
static let shared = AppLogger([
+ AppLogConsoleDestination(.debug),
])
ここまでで、コンソールに出力するロガーは完成した。
ここからは、既存のコードに影響を与えずに、ログの出力先を増やせることを確認していく。
Step5. Firebase Crashlytics に出力するロガーを追加する
Firebase Crashlytics は iOS アプリのクラッシュログ収集ツールのデファクトだと思う。Crashlytics では、クラッシュにつながったイベントのコンテキストをさらに詳細に把握するために、カスタム Crashlytics ログをアプリから記録できるようになっている。
Crashlytis へのログ送信を LogLevel warn 以上のログについて行いたい場合、専用の AppLogDestination を実装して AppLogger に追加することで実現できる。AppLogger の利用側に手を入れる必要はない。
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,
]))
}
}
/// 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 を使っている。デフォルトではログが 5 件溜まるか、10 秒経つと APIServerWriter.write() が呼ばれるので、その中でログをサーバーに送ればいい。
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)
}
}
/// shared はシングルトンオブジェクトを表す
static let shared = AppLogger([
AppLogConsoleDestination(.debug),
AppLogCrashlyticsDestination(.warn),
+ AppLogAPIServerDestination(.info, logRepository: IosapplogService.shared),
])