Now in REALITY Tech #92 Swiftマクロを活用したログAPIについて

今週の「Now in REALITY Tech」は、Swiftマクロを用いたログAPIによるREALITY iOSアプリの開発環境を効率化した対応についてiOSチームからお送りします。

実装したSwiftマクロのサンプルコード

はじめに、Swiftマクロを用いて作成したログAPIを紹介します。
SwiftマクロのログAPIはDebug、Warning、Errorの3種類作成しました。
それぞれ下記のように利用することができます。

// SwiftマクロをまとめたSwift Package
import RealitySwiftMacro
import OSLog

// Debug系ログ
#logDebug("debug message")
#logDebug("debug message", category: .tracking)

// Warning系ログ
#logWarning("warning message")
#logWarning("warning message", actionHandler: { message in
    print(message)
})

// Error系ログ
#logError(SampleError.invalid)
#logError(SampleError.invalid, category: .tracking, actionHandler: { error in
    print(error)
})

enum SampleError: Error {
    case invalid
}

次にSwiftマクロを使用したログAPIを実装することになった経緯にふれた後、
今回作成したログ用のSwiftマクロの内部について紹介します。

ログAPIにSwiftマクロを使用した背景

REALITY iOSのロガーは deprecatedとなっている os_log で実装されていたため、Logger を使用した新しいログ用APIに移行する必要があったことに加え、ログ周りは長年メンテナンスされていなかったためログの整理を行うことも目的としてありました。
また、Xcode 15からデバッグconsoleが改善され、ログを活用して今まで以上に効率的に開発ができるようになりました。
詳細についてはこちら:

コンソールログのイメージ

LoggerのAPIをモジュールにまとめた際の問題点

Loggerを使用することでログ発火させたファイル名、対象行、モジュールなどがログとしてわかるようになりますが、REALITY iOSアプリではモジュール分割されており、ログのAPIは `RealityLogger` というモジュールの `RealityLog` class にまとめられていました。

// RealityLog.swift

public final class Log {
    public static func debug(message: String, file: String = #file, line: Int = #line) {
        os_log("RealityLog 🔨, %@", log: .default, type: .debug, makeOutputMessage(message: message, file: file, line: line))
    }
}

その結果、ログAPIの利用は簡易にできますが出力されるログは下記のように全て `RealityLog` を指し示すようになってしまい、このままではLogger + デバッグconsoleを有効活用できないという問題がありました


ログAPIをまとめたRealityLogになっている

やりたいことは下記のように、ログの発火元のファイル名や対象行、モジュールをコンソールに表示し、コンソールから対象のログ発火元にjumpできるようにしたいです。

理想の状態

上記課題の解決方法

この問題をiOSチームに相談したところ、Swiftマクロを使用したら解決できるかも!?!? というご意見を頂きSwiftマクロを使用したログAPIの実装を試みることとなりました

Swiftマクロについて

Swiftマクロとは、Swiftのマクロは、コンパイル時にソースコードを変換するための機能です。これにより、繰り返しのコードを手作業で書く必要を避けることができます。マクロはコンパイル中にSwiftコード内のマクロを展開し、通常通りコードを構築します。

Macros transform your source code when you compile it, letting you avoid writing repetitive code by hand. During compilation, Swift expands any macros in your code before building your code as usual.

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/

Swiftマクロは2種類あり、自立型マクロ付属型マクロがあります。

自立型マクロ(Freestanding Macros)

自立型マクロは、宣言に付随せずに独立して使用されます。これらのマクロを呼び出すには、マクロ名の前に # を記述し、その後に括弧内に引数を記述します。例えば、#function#warningなどがこれに該当します。#functionはSwift標準ライブラリの関数()マクロを呼び出し、コンパイル時に現在の関数名に置き換えられます。#warningは、カスタムのコンパイル時警告を生成するために使用されます。自立型マクロは、値を生成したり、コンパイル時に特定のアクションを実行することができます。

func myFunction() {
    print("Currently running \(#function)")
    #warning("Something's wrong")
}

付属型マクロ(Attached Macros)

付属型マクロは、特定の宣言に付随して使用されます。これらのマクロを呼び出すには、マクロ名の前に@を記述し、その後に括弧内に引数を記述します。付属型マクロは、それが付随する宣言にコードを追加します。例えば、新しいメソッドを定義したり、プロトコルへの適合を追加するなどの機能があります。公式ドキュメントでは付属型マクロの一例として@OptionSetマクロを作成し、OptionSetプロトコルへの適合を追加する実装例が紹介されています。

@OptionSet<Int>
struct SundaeToppings {
    private enum Options: Int {
        case nuts
        case cherry
        case fudge
    }
}

今回作成するログAPIは自立型マクロで作成します。

ログAPIの要件

改めてログAPIの要件を整理すると、下記の要件を満たすAPIを実装する必要があります。

  • ログの種類により出力するログ分けできること

    • Loggerのinitializerでcategoryを渡せるので、categoryによりログの種類を分けるようにします

  • Crashlyticsへのログ送信もセットでできること

    • 移行前では、Warning・Error系のログ実行時にCrashlyticsへのログ送信も行っているためこちらも実行できるようにします

  • Logger + Xcode コンソールログを有効活用できること

ログカテゴリの分類

現状は下記のように、カテゴリを分類しています。
例えば、REALITYアプリではUnityを使用してアバターを表示しているため、Unityに関連したログをまとめられるようにしています。

public enum LogCategory: String {
    case `default`
    case unity
    case tracking
}

Swiftマクロの作成

SwiftマクロはSwift Packageとして作成することが推奨されているため、下記からSwift Macroを選択し、RealitySwiftMacro と命名して新規作成します。
新規作成するとテンプレートで自立型マクロやマクロClient、Unit Testが生成されるためテンプレートを参考にマクロを作成することができます。

Swiftマクロの新規作成

自立型マクロの宣言

Debugログマクロは message、categoryを受け取り、
Errorログマクロは Error、category、ログ実行後のclosureを受け取るようにして展開します。
内部の実装はそれぞれ、DebugLoggerMacro、ErrorLoggerMacro で実装されています。

// カテゴリのデフォルト値をdefaultでセット
@freestanding(expression)
public macro logDebug(_ message: String, category: LogCategory = .default) -> Void = #externalMacro(module: "LoggerMacroMacros", type: "DebugLoggerMacro")


// actionHandlerにCrashlyticsにErrorログ送信処理をセット
@freestanding(expression)
public macro logError(_ error: Error,
category: LogCategory = .default,
actionHandler: (Error) -> Void = Log.sendErrorCrashlytics) -> Void = #externalMacro(module: "LoggerMacroMacros", type: "ErrorLoggerMacro")

マクロの内部実装

下記にDebugログマクロの内部実装を紹介します。
返り値でSyntaxを返していますが、内容としてはLoggerの作成とdebug APIを実行するようになっています。

public struct DebugLoggerMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let message = node.argumentList.first?.expression else {
            fatalError("Expected an argument")
        }

        if let category = node.argumentList.first(where: { $0.label?.text == "category" })?.expression {
            return """
              Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: LogCategory\(category).rawValue).debug(\(message))
            """
        } else {
            return """
              Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: LogCategory.default.rawValue).debug(\(message))
            """
        }
    }
}

作成したSwiftマクロの組み込み

実装したマクロを利用したコードを一部抜粋して紹介します。
APIのインターフェースとしては、今までと同様にシンプルにしつつLoggerを用いることで効率的な開発環境にすることができました!

// トラッキングログにDebugログマクロを使用したサンプル
public func track(info: TrackingInfo) {
   #logDebug("track: \(String(reflecting: info))", category: .tracking)
   ...
}

// Facebook SDK利用時のErrorログマクロを使用したサンプル
func login() async throws -> SocialLoginResult {
...
  GraphRequest(graphPath: "/me", parameters: ["fields": "id, first_name"])
      .start(completion: { [weak self] _, result, error in
          if let error {
              #logError(error)
              return
          }
      })
...

オマケ

その1 SwiftマクロのUnit Test

SwiftマクロのUnit Testを紹介します。
期待する展開後のsyntaxを記述して、Swiftマクロを実行し展開後のコードとマッチしているかTestできます。

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest

// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests.
#if canImport(LoggerMacroMacros)
import LoggerMacroMacros

let testMacros: [String: Macro.Type] = [
    "logDebug": DebugLoggerMacro.self,
    "logWarning": WarningLoggerMacro.self,
    "logError": ErrorLoggerMacro.self,
]
#endif

final class LoggerMacroTests: XCTestCase {
    func testDebugLoggerMacro() throws {
        #if canImport(LoggerMacroMacros)
        assertMacroExpansion(
            // マクロの実行
            """
            #logDebug("debug message", category: .default)
            """,
            // 期待する展開後のコード
            expandedSource: """
            Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: LogCategory.default.rawValue).debug("debug message")
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }
...

その2 Swiftマクロ用 環境変数の紹介

今回Swiftマクロ用に追加したSwift Package `RealitySwiftMacro` はREALITYアプリ本体とは別のGitHub プライベートリポジトリで管理し、REALITYアプリ側で `RealitySwiftMacro` をLinkするようにしていました。
そのため、CIサービスにおいてPackageの依存解決時に下記エラーが発生していました。

Target 'LoggerMacroMacros' must be enabled before it can be used.

解決方法としては、PackageのバリデーションをスキップすることでPackageを有効にできるので、
CI環境で下記のように環境変数 IDESkipMacroFingerprintValidation を `YES` にしておくとPackageのバリデーションをスキップすることができます。

// Swiftマクロのバリデーションをスキップ
defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES

こちらを参照:https://forums.swift.org/t/is-there-a-way-to-programmatically-allow-trust-the-compiler-plugin-to-run-from-the-command-line/65690/2

まとめ

Swiftマクロを使用することで、LoggerおよびXcodeコンソールを最大限に有効活用しつつスッキリとしたインターフェースにすることができました!
今回はLoggerというシンプルな処理に対してSwiftマクロを活用しましたが、マクロの実装にはSwiftSyntaxにおける知識が必要となるため、あまり多用し過ぎず複雑な処理には使用を避けるのが無難なのかなと感じました。

今後もSwiftマクロの使用方法には考慮しつつ、有効活用していきたいと思います!