Swift - ログのバッファリングとリトライをするだけの小さいライブラリ「BufferedLogger」を作った

# tl;dr

- アプリの運用では、クライアントログを監視するニーズがある
- 細かい監視ニーズを考慮すると自前でログ集約サーバーを持った方が良い
- クライアントログをサーバーに送る場合は単純に送信するだけでなく、ログのバッファリングとリトライを考慮する必要がある
- [cookpad/Puree-Swift](https://github.com/cookpad/Puree-Swift) がすでにあるが、タグシステムなど上記の必要以上に高度な機能も持っている
- ログのバッファリングとリトライだけに特化してそれ以外を省くことで、使うのが簡単であることを重視したライブラリとして[BufferedLogger](https://github.com/yoheimuta/BufferedLogger)を作った
- 「BufferedLogger」 はプロダクション投入済みで特に問題は発生していない

# 背景

ログ集約サーバーのクライアントを用意するときに、当然ログのバッファリングとリトライをしたいと考えた。
また、リトライが成功しなくても次回起動時には再びリトライを再開するようにもしたい。つまり、ロストしないようにしたい。

`cookpad/Puree-Swift` の利用も検討したが、タグシステムは必要なかったし、BufferedOutput 以外の Filter や Output も必要なかった。
そうなると、コンストラクタを書くだけでも、必要のない機能の方が目立ってくる。
また、ログ周りはメモリやディスクが関係してくるので深刻な運用上のトラブルシューティングの必要も出てくることが想定された。
そういうわけで、自分たちにとって必要最低限の保守しやすい小さいコードの方が望ましいと考えた。

一方でテストはしっかり書く必要があったので、独立したライブラリとして実装することにした。
構成は Puree-Swift の実装を適宜参考にさせてもらった。Puree-Swift 同様、ほかのライブラリへの依存はなし。
また、当時は Puree-Swift はスレッドセーフではなかったので、そこも対応する必要があった。

結果的に [コスメ専門フリマアプリ abby(アビー) ](https://itunes.apple.com/jp/app/abby-%E3%82%A2%E3%83%93%E3%83%BC-%E3%82%B3%E3%82%B9%E3%83%A1%E5%B0%82%E9%96%80%E3%83%95%E3%83%AA%E3%83%9E%E3%82%A2%E3%83%97%E3%83%AA/id1386057672?mt=8) というアプリに入れることができた。問題は特に起きていない。

- 実際のロガーとしての使い方は https://qiita.com/yoheimuta/items/f698b2ba857e5ec5c6d6#step6-リモートサーバーに出力するロガーを実装する も参照。`複数の出力先に対応したロガー`を作った上で、その出力先の一つとして、今回テーマになっているログ集約サーバーのクライアントを追加している。

# 動作環境

2018.11.02 時点。

- iOS 9.0 以上
- Xcode 9.x - Swift4

# インストール

### Carthage

```
github "yoheimuta/BufferedLogger"
```

# ログ出力クラスの定義

利用には最低限 Writer プロトコルを実装する必要がある。
バッファリングされたログが定期的に write メソッドの引数に渡されて呼び出される。

リトライが指定回数失敗するとメモリから削除されるので、それ以上処理を行うことはない。
そして、次回の起動時には、ディスクから読み込んで再びリトライする。

デフォルトでディスクに保存するログの件数上限を 1000 件にしている。設定で変更可能。
Puree-Swift にはそういう制限がない（ようにみえる）。
上限はあったほうが安全なことが多いと思ったので入れている。

```swift
/// Writer represents a protocol to write a chunk of logs.
public protocol Writer {
    /// write is invoked with logs periodically.
    ///
    /// - Parameters:
    ///   - chunk: a set of log entries
    ///   - completion: call with true when the write action is success.
    func write(_ chunk: Chunk, completion: @escaping (Bool) -> Void)
}
```

ここでは単純に print する例を紹介する。
実際にはここで自前のログ集約サーバーに送信する処理を書くことが想定される。

```swift
//
//  MyWriter.swift
//  Demo
//
//  Created by YOSHIMUTA YOHEI on 2018/07/25.
//  Copyright © 2018年 YOSHIMUTA YOHEI. All rights reserved.
//

import BufferedLogger
import Foundation

class MyWriter: Writer {
    func write(_ chunk: Chunk, completion: @escaping (Bool) -> Void) {
        print("chunk is \(chunk)")

        chunk.entries.forEach {
            print("entry is \($0)")
        }

        completion(true)
    }
}
```

# ロガーの作成とログのポスト

BFLogger に Writer プロトコル実装を渡せばロガーが作成される。
BFLogger の post メソッドに Data 型を渡せばログがポストされる。

ここでは、細かい設定はデフォルト引数を利用している。
よしなにログをバッファリングしつつリトライする設定になっているので、デフォルト引数のままでも基本的に支障はないと思う。

```swift
//
//  AppDelegate.swift
//  Demo
//
//  Created by YOSHIMUTA YOHEI on 2018/07/25.
//  Copyright © 2018年 YOSHIMUTA YOHEI. All rights reserved.
//

import BufferedLogger
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    var logger: BFLogger!

    func application(_: UIApplication,
                     didFinishLaunchingWithOptions _: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        let writer = MyWriter()
        logger = BFLogger(writer: writer)
        logger.post("1".data(using: .utf8)!)
        logger.post("2".data(using: .utf8)!)
        logger.post("3".data(using: .utf8)!)
        return true
    }

    func applicationDidEnterBackground(_: UIApplication) {
        logger.suspend()
    }

    func applicationWillEnterForeground(_: UIApplication) {
        logger.resume()
    }
}
```

スレッドセーフに実装されているのでメインスレッド以外から利用することもできる。

# オプショナルな設定

BFLogger のコンストラクタに Config クラスを渡すことでデフォルトの挙動を変更できる。
デフォルトの挙動は次の通り。

- flushEntryCount = 5
  - write メソッドに渡す chunk は最大でも 5 件。また 5 件バッファされたら write メソッドを呼ぶ
- flushInterval = 10
  - 10 秒ごとに write メソッドを呼ぶ。ちなみに、その前に flushEntryCount に達しても write メソッドを呼ぶ
- retryRule = DefaultRetryRule(retryLimit: 3)
  - 3 回までリトライする。詳細は後述。
- maxEntryCountInStorage = 1000
  - 1000 件までディスクに保存する
- storagePath = defaultStoragePath
  - ディスクの保存先。複数の BFLogger インスタンスを使う場合に別のパスを渡す必要がある。

```swift
public let defaultStoragePath = "Buffer"

/// Config represents a configuration for buffering and writing logs.
public struct Config {
    /// flushEntryCount is the maximum number of entries per one chunk.
    /// When the number of entries of buffer reaches this count, it starts to write a chunk.
    public let flushEntryCount: Int

    /// flushInterval is a interval to write a chunk.
    public let flushInterval: TimeInterval

    /// retryRule is a rule of retry.
    public let retryRule: RetryRule

    /// maxEntryCountInStorage is a max count of entry to be saved in the storage.
    /// When the number of entries in the storage reaches this count, it starts to
    /// delete the older entries.
    public let maxEntryCountInStorage: Int

    /// storagePath is a path to the entries.
    /// When you uses multiple BFLogger, you must set an unique path.
    public let storagePath: String

    public init(flushEntryCount: Int = 5,
                flushInterval: TimeInterval = 10,
                retryRule: RetryRule = DefaultRetryRule(retryLimit: 3),
                maxEntryCountInStorage: Int = 1000,
                storagePath: String = defaultStoragePath) {
        self.flushEntryCount = flushEntryCount
        self.flushInterval = flushInterval
        self.retryRule = retryRule
        self.maxEntryCountInStorage = maxEntryCountInStorage
        self.storagePath = storagePath
    }

    /// default is a default configuration.
    public static let `default` = Config()
}
```

### リトライルール

リトライの設定は RetryRule プロトコルを実装したクラスを Config クラスのコンストラクタに渡して行う。

- retryLimit はリトライ回数で、これ以上失敗するとメモリから削除される。
- delay はリトライ間隔の秒数を返す関数。

```swift
/// RetryRule is a rule about retry.
public protocol RetryRule {
    /// retryLimit is a retry count.
    /// The chunk is deleted only from memory after it failed more than this number of times.
    var retryLimit: Int { get }

    /// delay is an interval to decide how long to wait for a next retry.
    func delay(try count: Int) -> TimeInterval
}
```

デフォルトの delay 関数は Exponential backoff を実装している。

```swift
/// DefaultRetryRule is a default implementation of RetryRule.
public class DefaultRetryRule: RetryRule {
    public let retryLimit: Int

    public init(retryLimit: Int) {
        self.retryLimit = retryLimit
    }

    public func delay(try count: Int) -> TimeInterval {
        return 2.0 * pow(2.0, Double(count - 1))
    }
}
```