しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

os_signpostを投げるURLProtocolを作る

プロジェクトが変わるたびに毎回似たようなものを作っている気がしたので、メモしておくことにしました。
単純なAPIのパフォーマンスならNewRelicやらDatadogやらで見れば良いですが、アプリ内部のボトルネックの検証をするのには役に立つ事があります。

こちらは主に外部ライブラリに通信関連を委ねている場合に、汎用的に差し込む目的で利用します。
ラッパークラスを作成している場合は、URLProtocolを利用せず、そちらに直接os_signpostの処理を書いていくのがわかりやすそうです。

コード

URLProtocolの実装は以下のようになります。

実際に通信を行う必要があるので、URLSessionを利用します。
Loggerについては既にどこかで定義されていれば、それを利用してもOKです。 なければ、OSSignposter.init(subsystem:category:)で定義できます。

import Foundation
import os

final class SignpostURLProtocol: URLProtocol {
    static let logger = Logger(subsystem: "com.flyingalpaca.sample", category: "API")
    private let signposter = OSSignposter(logger: SignpostURLProtocol.logger)
    private let session: URLSession = .init(configuration: .default)
    private var sessionTask: Task<Void, any Error>?
    private var state: OSSignpostIntervalState?
    
    override static func canInit(with _: URLRequest) -> Bool {
        true
    }
    
    override static func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }
    
    override func startLoading() {
        let id = signposter.makeSignpostID()
        let message = [request.httpMethod, request.url?.absoluteString]
            .compactMap { $0 }
            .joined(separator: " ")
        
        state = signposter.beginInterval("API Call", id: id, "\(message)")
        sessionTask = Task {
            let (_, response) = try await session.data(for: request)
            let statusCode = (response as? HTTPURLResponse)?.statusCode
            endInterval(message: statusCode.map(String.init))
        }
    }
    
    override func stopLoading() {
        endInterval(message: "canceled")
        sessionTask?.cancel()
    }
    
    private func endInterval(message: String?) {
        guard let state else { return }

        if let message {
            signposter.endInterval("API Call", state, "\(message)")
        } else {
            signposter.endInterval("API Call", state)
        }
        self.state = nil
    }
}

利用時はURLSessionConfigurationを利用します。 デバッグ時のみ処理したい場合は、この部分かcanInitに判定を追加すると良さそうです。

let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [SignpostURLProtocol.self]
let session = URLSession(configuration: configuration)

Instrumentsでの動作

このようになります。

Cloudflareへのドメイン移管時のSSLエラーの解消例

ブログのドメインのトランスファーを実施した際にSSLエラーが出たため、対処方法の一例を残しておきます。
Cloudflareビキナーです。

発生した現象

自分の場合は、対象ドメインのトランスファー完了後にChromeでアクセスした際に、ERR_SSL_VERSION_OR_CIPHER_MISMATCHというエラーが表示されました。

Safariなど別のブラウザでも同様の表示になっています。

Proxied DNS recordsの設定

CNAMEレコードをはてなブログ用に設定しているのですが、digを叩いてみると、Aレコードだけが表示されています。

dig +noall +ans www.volatile-void.com
www.volatile-void.com.  247    IN  A   104.21.93.160
www.volatile-void.com.  247    IN  A   172.67.212.17

調べてみると、こちらはCloudflareのサーバーのIPのようで、デフォルトで有効なProxied DNS recordsという機能の影響のようです。
DNSレコードを確認すると、確かにプロキシ機能が有効になってみました。

developers.cloudflare.com

試しにプロキシ機能をオフにしてみます。(灰色の雲の部分です)

dig +noall +ans www.volatile-void.com 
www.volatile-void.com.  68 IN  CNAME   hatenablog.com.
hatenablog.com.     28 IN  A   54.199.90.60
hatenablog.com.     28 IN  A   35.75.255.9

Cloudflareのサーバーを介さなくなったため、CNAMEレコードが表示され、Aレコードもはてなのものを指すようになりました。
ブラウザからアクセスしても、SSLエラーが出なくなります。

SSL/TLS encryption modeの設定

では、SSLエラーの直接の原因は何かというと、プロキシ機能有効時にSSL/TLSの暗号化モードを自動で設定する、もう一つのCloudflareの機能によるものでした。

developers.cloudflare.com

こちらはブラウザ-Cloudflare-オリジンサーバー間の暗号化モードを自動で判断して設定する機能ですが、オリジンサーバーがはてなの場合はFlexibleとなってしまい、 Cloudflareからはてなサーバーへの通信がHTTPとなってしまっていました。

例えば、暗号化モードをFull (strict)に手動で変更することで、ここの通信がHTTPSで行われるようになり、プロキシ機能を有効にしたまま、SSLエラーを解消することができます。

結局どうする

プロキシ機能を有効にしておくことで、恩恵がありそうではあるものの、SEO影響が読めなかったため、一旦プロキシオフのDNS onlyの利用にすることにしました。

しばらくして安定したタイミングで、プロキシありで運用してみて、メリットデメリットを比較してみたいと思います。

コードからXcodeのプロジェクトルートを取得する

シミュレータでのデバック時やXcode previewsを利用する際に、差分管理の都合上、プロジェクトにリソースを追加せずにデバッグしたいケースがあります。
そのような場合に、リソースのロードにローカルのパスを参照する必要があるため、Xcodeのプロジェクトルートをコードから探る必要が生じます。

ということでDEBUG onlyで、プロジェクトルートを辿る方法を考えてみます。
(今回はSRCROOTを使っていますが、PROJECT_DIRなどでも同様です。)

ビルド時にスクリプトでInfo.plistに書き込む

PlistBuddyを使って、Info.plistに書き込む方法です。
Build PhasesのCopy Bundle Resourcesの後にスクリプトを追加して、.app配下のInfo.plistに項目を追加します。
(プロジェクト内のInfo.plistには項目は追加されません)

if [ "${CONFIGURATION}" = "Debug" ]; then
  /usr/libexec/PlistBuddy -c "Add :RootDir string ${SRCROOT}" "${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}"
fi

PlistBuddyで書き込む際に、ENABLE_USER_SCRIPT_SANDBOXINGNOにする必要があります。

Swiftからは、Bundle.main.infoDictionaryで取得できます。

let projectRootPath = Bundle.main.infoDictionary?["RootDir"] as? String

SRCROOT以外にも、gitで管理している場合はそちらを使うことができます。

filePathマクロを使う

#filePathマクロを使うことで、あるファイルのパスからプロジェクトルートを探す方法です。
ファイルの置き場所とプロジェクトルートの相対パスに処理が依存するので、ファイルの移動の際に注意する必要があります。

let fileUrl = URL(filePath: #filePath)
let components = fileUrl.pathComponents.dropLast().dropFirst() // 相対パスに応じて加工
let projectRootPath = components.joined(separator: "/")

developer.apple.com

リソース置き場に上記の内容を含んだファイルを置いておけば、相対パスの考慮が必要なくなります。

ObjC経由でpreprocessor macroを使う

Swiftからは直接preprocessor macroの値を参照することはできないですが、Objective-Cで定数として定義し直して利用する方法もあります。

Build SettingsのApple Clang - Preprocessingから、Preprocessor MacrosでROOTDIR=$(SRCROOT)のように定義します。

Objective-Cの適当なヘッダーに定数を定義して、Bridging-Headerを介してSwiftから扱います。

#import <Foundation/Foundation.h>

#ifdef DEBUG
static NSString *const projectRootPath = @OS_STRINGIFY(ROOTDIR);
#endif

SwiftからはprojectRootPathで参照できます。