🎩

初めてのswift-configuration

に公開

swift-configurationは、Appleが開発したSwiftアプリケーションのための統合設定管理ライブラリです。環境変数、設定ファイル、コマンドライン引数など、さまざまな設定ソースを統一されたAPIで扱うことができます。

従来のアプローチの課題

多くのSwiftアプリケーションでは、設定を以下のように直接扱っています:

// 環境変数を直接読み取る
let dbHost = ProcessInfo.processInfo.environment["DATABASE_HOST"] ?? "localhost"
let dbPortString = ProcessInfo.processInfo.environment["DATABASE_PORT"] ?? "5432"
let dbPort = Int(dbPortString) ?? 5432

// ハードコーディングされた設定
let apiTimeout: TimeInterval = 30.0
let maxRetries = 3

主な課題:

  • 設定値がコードベース全体に散在
  • 型変換の手動処理とエラーハンドリング
  • 設定ソースごとに異なる読み込みコード
  • テストでのモック化が困難

swift-configurationの主な特徴

1. 統一されたAPI

設定ソースに関わらず、同じ方法でアクセス:

let config = ConfigReader(providers: [
    CommandLineArgumentsProvider(),    // 最優先
    EnvironmentVariablesProvider(),    // 次点
    try await JSONProvider(filePath: "config.json"), // その次
    InMemoryProvider(values: defaults) // 最後のフォールバック
])

// どのプロバイダーから来ても、同じインターフェース
let dbHost = config.string(forKey: "database.host", default: "localhost")

2. 階層的な設定管理

複数の設定ソースを優先順位付きで組み合わせ、柔軟な設定上書きを実現。

3. 型安全なアクセス

let port = config.int(forKey: "server.port", default: 8080)      // Int
let timeout = config.double(forKey: "timeout", default: 30.0)    // Double
let debug = config.bool(forKey: "debug", default: false)         // Bool

4. ホットリロード

設定ファイルの変更をリアルタイムで検知し、アプリケーションの再起動なしに反映。

5. テスト容易性

let testConfig = ConfigReader(
    providers: [
        InMemoryProvider(values: [
            "database.host": "test-db",
            "database.port": 5433
        ])
    ]
)
// ファイルシステムや環境変数に依存しない完全な制御

swift-configurationとは

概要と特徴

swift-configurationは、Appleが公式に開発・メンテナンスしているSwift設定管理ライブラリです。Swift Server Workgroupの一環として開発されており、サーバーサイドSwiftアプリケーションを主なターゲットとしていますが、CLIツールやその他のSwiftアプリケーションでも利用できます。

このライブラリの中核となるのは、プロバイダーベースのアーキテクチャです。設定ソース(環境変数、JSONファイル、YAMLファイルなど)はすべて「プロバイダー」として抽象化されており、共通のインターフェースを通じてアクセスされます。アプリケーションは、これらのプロバイダーを組み合わせて使用することで、複雑な設定管理を簡潔に実現できます。

想像してみてください。あなたのアプリケーションが、開発環境ではローカルのJSON設定ファイルから設定を読み込み、ステージング環境では環境変数を優先し、本番環境では環境変数とホットリロード可能なJSONファイルの両方を使用するとします。swift-configurationなら、これらすべてのシナリオを同じコードベースで扱えます。

#if DEBUG
let config = ConfigReader(providers: [
    try await JSONProvider(filePath: "config.dev.json"),
    InMemoryProvider(values: defaults)
])
#else
let env = ProcessInfo.processInfo.environment["ENVIRONMENT"] ?? "production"
let config = try await createConfigForEnvironment(env)
#endif

// どの環境でも同じ方法でアクセス
let dbHost = config.string(forKey: "database.host", default: "localhost")

型安全性は、swift-configurationの重要な特徴の一つです。設定値を取得する際には、期待する型を明示的に指定します。ライブラリは自動的に型変換を試み、失敗した場合は適切にエラーを処理します。これにより、ランタイムでの予期しない型エラーを防ぐことができます。

設定の読み取りには、3つの異なるアクセスパターンが用意されています:

  1. 同期的に現在の値を取得する get - メモリ内の値を即座に返す
  2. 非同期で最新の値を取得する fetch - 権威ソースから最新値を取得
  3. 設定の変更をリアルタイムで監視する watch - 設定が変更されたら通知を受け取る

これらのパターンを使い分けることで、アプリケーションの要件に応じた最適な設定アクセスを実現できます。

// 同期アクセス: 起動時の設定読み込み
let port = config.int(forKey: "server.port", default: 8080)

// 非同期アクセス: 最新の設定を取得
let apiKey = try await config.fetchString(forKey: "api.key", isSecret: true)

// リアクティブアクセス: 設定変更を監視
for await logLevel in config.watchString(forKey: "logging.level", default: "info") {
    logger.logLevel = Logger.Level(rawValue: logLevel) ?? .info
}

主なユースケース

swift-configurationは、さまざまなタイプのSwiftアプリケーションで活用できます。それぞれのユースケースを詳しく見ていきましょう。

Webアプリケーション・マイクロサービス

最も一般的なのは、Webアプリケーションやマイクロサービスでの利用です。これらのアプリケーションでは、HTTPサーバーのポート番号やホスト名、データベース接続情報、APIエンドポイントなど、多数の設定パラメータを管理する必要があります。

import Vapor
import Configuration

@main
struct App {
    static func main() async throws {
        let config = ConfigReader(providers: [
            EnvironmentVariablesProvider(),
            try await JSONProvider(filePath: "/etc/myapp/config.json"),
            InMemoryProvider(values: [
                "http.host": "0.0.0.0",
                "http.port": 8080,
                "database.pool.min": 5,
                "database.pool.max": 20
            ])
        ])

        var env = try Environment.detect()
        let app = Application(env)

        // 設定から値を取得してVaporアプリケーションを構成
        let host = config.string(forKey: "http.host", default: "127.0.0.1")
        let port = config.int(forKey: "http.port", default: 8080)

        app.http.server.configuration.hostname = host
        app.http.server.configuration.port = port

        try app.run()
    }
}

swift-configurationを使用することで、これらの設定を環境変数や設定ファイルから一元的に読み込み、環境に応じて柔軟に変更できます。

CLIツール

CLIツールの開発でも、swift-configurationは力を発揮します。コマンドライン引数、設定ファイル、環境変数を統合して扱うことで、ユーザーに柔軟な設定オプションを提供できます。

import ArgumentParser
import Configuration

@main
struct MyTool: AsyncParsableCommand {
    @Option(name: .long)
    var configFile: String?

    @Option(name: .long)
    var apiKey: String?

    mutating func run() async throws {
        var providers: [ConfigProvider] = []

        // コマンドライン引数を最優先
        if let apiKey = apiKey {
            providers.append(InMemoryProvider(values: ["api.key": apiKey]))
        }

        // 設定ファイル
        if let configFile = configFile {
            providers.append(try await JSONProvider(filePath: configFile))
        } else {
            // デフォルトの設定ファイルパス
            let homeDir = FileManager.default.homeDirectoryForCurrentUser
            let defaultConfigPath = homeDir.appendingPathComponent(".mytool/config.json")
            if FileManager.default.fileExists(atPath: defaultConfigPath.path) {
                providers.append(try await JSONProvider(filePath: defaultConfigPath.path))
            }
        }

        // 環境変数
        providers.append(EnvironmentVariablesProvider())

        // デフォルト値
        providers.append(InMemoryProvider(values: [
            "api.endpoint": "https://api.example.com",
            "timeout": 60.0
        ]))

        let config = ConfigReader(providers: providers)

        // ツールのメインロジック
        let endpoint = config.string(forKey: "api.endpoint", default: "https://api.example.com")
        let apiKey = try config.requireString(forKey: "api.key")

        print("Connecting to \(endpoint)...")
        // API呼び出しなど
    }
}

この例では、デフォルト設定を設定ファイルで定義し、必要に応じてコマンドライン引数で上書きする、という使い方が簡単に実現できます。

マイクロサービスアーキテクチャ

マイクロサービスアーキテクチャでは、複数のサービス間で設定を共有したり、特定のサービスだけに固有の設定を持たせたりする必要があります。swift-configurationの階層的な設定管理機能を使えば、共通設定ファイルとサービス固有の設定ファイルを組み合わせて、効率的に設定を管理できます。

// user-service の設定
let config = ConfigReader(providers: [
    EnvironmentVariablesProvider(),
    // サービス固有の設定が優先
    try await ReloadingJSONProvider(
        filePath: "/etc/services/user-service/config.json",
        pollInterval: .seconds(30)
    ),
    // 共通設定
    try await ReloadingJSONProvider(
        filePath: "/etc/services/common/config.json",
        pollInterval: .seconds(30)
    ),
    InMemoryProvider(values: serviceDefaults)
])

このアプローチにより、全サービス共通の設定(ログフォーマット、監視エンドポイントなど)と、サービス固有の設定(データベース接続情報、APIエンドポイントなど)を明確に分離できます。

モバイルアプリケーション

モバイルアプリケーション開発でも、特にサーバーから動的に設定を取得するケースで有用です。リモート設定サーバーから取得した設定とローカルのデフォルト設定を組み合わせることで、アプリケーションの動作を柔軟に制御できます。

// カスタムプロバイダー: リモート設定サーバーから取得
struct RemoteConfigProvider: ConfigProvider {
    let providerName = "RemoteConfigProvider"
    let apiClient: APIClient

    func snapshot() async throws -> ConfigSnapshot {
        let remoteConfig = try await apiClient.fetchConfig()
        return ConfigSnapshot(from: remoteConfig)
    }

    // その他のメソッド実装...
}

// アプリケーションで使用
let config = ConfigReader(providers: [
    RemoteConfigProvider(apiClient: client),
    InMemoryProvider(values: bundledDefaults)
])

他の設定管理手法との比較

swift-configurationを理解するために、他の一般的な設定管理手法と比較してみましょう。

環境変数への直接アクセス

従来のアプローチでは、ProcessInfo.processInfo.environmentを直接使用して環境変数にアクセスします。

// 従来のアプローチ
let dbHost = ProcessInfo.processInfo.environment["DATABASE_HOST"] ?? "localhost"
let dbPortString = ProcessInfo.processInfo.environment["DATABASE_PORT"] ?? "5432"
let dbPort = Int(dbPortString) ?? 5432

// swift-configurationを使用
let config = ConfigReader(providers: [
    EnvironmentVariablesProvider(),
    InMemoryProvider(values: ["database.host": "localhost", "database.port": 5432])
])
let dbHost = config.string(forKey: "database.host", default: "localhost")
let dbPort = config.int(forKey: "database.port", default: 5432)

swift-configurationの利点:

  • 型変換が自動的に行われる
  • デフォルト値の管理が一元化される
  • テストでのモック化が容易
  • 環境変数とファイルを簡単に組み合わせられる

UserDefaults

UserDefaultsは、主にmacOSやiOSアプリケーションでの永続的な設定保存に使用されますが、サーバーサイドアプリケーションや環境変数との統合には適していません。

// UserDefaults: ユーザー設定の永続化
UserDefaults.standard.set(true, forKey: "notifications.enabled")
let notificationsEnabled = UserDefaults.standard.bool(forKey: "notifications.enabled")

// swift-configuration: アプリケーション設定
let config = ConfigReader(providers: [...])
let apiEndpoint = config.string(forKey: "api.endpoint", default: "https://api.example.com")

使い分けの指針:

  • UserDefaults: ユーザー固有の永続的な設定(通知設定、UI設定など)
  • swift-configuration: アプリケーション全体の設定、環境依存の設定

両者を組み合わせて使用することも可能です:

// ユーザー設定をConfigProviderとして提供
struct UserDefaultsProvider: ConfigProvider {
    let providerName = "UserDefaultsProvider"
    let defaults: UserDefaults

    func value(forKey key: ConfigKey, type: ConfigValueType) throws -> ConfigValue? {
        let keyString = key.components.joined(separator: ".")
        // UserDefaultsから値を取得して ConfigValue に変換
        return defaults.object(forKey: keyString).map { ConfigValue(from: $0) }
    }
    // ...
}

外部設定管理サービス

AWS Systems Manager Parameter StoreやHashiCorp Consulといった外部設定管理サービスとの連携も可能です。swift-configurationのプロバイダーインターフェースを実装することで、これらのサービスをシームレスに統合できます。

// AWS Systems Manager Parameter Store プロバイダー
struct AWSParameterStoreProvider: ConfigProvider {
    let providerName = "AWSParameterStoreProvider"
    let client: SSMClient
    let path: String

    func fetchValue(forKey key: ConfigKey, type: ConfigValueType) async throws -> ConfigValue? {
        let parameterName = "\(path)/\(key.components.joined(separator: "/"))"

        let input = GetParameterInput(
            name: parameterName,
            withDecryption: true
        )

        let output = try await client.getParameter(input: input)
        guard let value = output.parameter?.value else {
            return nil
        }

        return try ConfigValue(stringValue: value, as: type)
    }

    // その他のメソッド実装...
}

// 使用例
let config = ConfigReader(providers: [
    EnvironmentVariablesProvider(),  // 環境変数が最優先
    AWSParameterStoreProvider(       // クラウドの設定
        client: ssmClient,
        path: "/myapp/production"
    ),
    InMemoryProvider(values: defaults) // フォールバック
])

このアプローチにより、クラウドネイティブな設定管理を実現しながら、ローカル開発環境では環境変数やファイルを使用する、という柔軟な運用が可能になります。


3. 基本コンセプト

swift-configurationを効果的に使用するには、4つの基本コンセプトを理解することが重要です。これらのコンセプトは、ライブラリのアーキテクチャの基盤となっています。

プロバイダー(Provider)

swift-configurationの中心的な概念は「プロバイダー」です。プロバイダーは、設定値がどこから来るのか(環境変数、ファイル、メモリなど)を抽象化したものです。すべてのプロバイダーはConfigProviderプロトコルに準拠しており、共通のインターフェースを通じて設定値を提供します。

この抽象化により、アプリケーションコードは設定ソースの詳細を知る必要がありません。設定値を読み取るコードは、その値が環境変数から来るのか、JSONファイルから来るのか、あるいはメモリ内のデフォルト値なのかを意識することなく、統一された方法で値にアクセスできます。

// アプリケーションコードは設定ソースを意識しない
func startServer(config: ConfigReader) async throws {
    let host = config.string(forKey: "server.host", default: "0.0.0.0")
    let port = config.int(forKey: "server.port", default: 8080)

    // このコードは、設定が環境変数から来ても、
    // JSONファイルから来ても、同じように動作する
    try await bindServer(host: host, port: port)
}

プロバイダーには、静的なものと動的なものがあります。

静的プロバイダーは、アプリケーション起動時に一度設定を読み込み、以降は変更しません:

// 静的プロバイダーの例
let envProvider = EnvironmentVariablesProvider()
let jsonProvider = try await JSONProvider(filePath: "/etc/config.json")
let memoryProvider = InMemoryProvider(values: ["timeout": 30.0])

動的プロバイダーは、定期的にソースの変更をチェックし、変更があれば自動的に設定を更新します:

// 動的プロバイダーの例
let reloadingProvider = try await ReloadingJSONProvider(
    filePath: "/etc/config.json",
    pollInterval: .seconds(30)
)

// 設定変更を監視
for await timeout in config.watchDouble(forKey: "api.timeout", default: 30.0) {
    print("Timeout setting changed to: \(timeout)")
    apiClient.updateTimeout(timeout)
}

カスタムプロバイダーを実装することも可能です。例えば、データベースから設定を読み込むプロバイダーや、HTTPSエンドポイントから設定を取得するプロバイダーを作成できます。

// カスタムプロバイダーの例
struct DatabaseConfigProvider: ConfigProvider {
    let providerName = "DatabaseConfigProvider"
    let database: Database

    func value(forKey key: ConfigKey, type: ConfigValueType) async throws -> ConfigValue? {
        let keyString = key.components.joined(separator: ".")

        // データベースから設定を取得
        let row = try await database.query(
            "SELECT value FROM config WHERE key = ?",
            [keyString]
        ).first

        guard let valueString = row?["value"] as? String else {
            return nil
        }

        // 文字列を適切な型に変換
        return try ConfigValue(stringValue: valueString, as: type)
    }

    func snapshot() async throws -> ConfigSnapshot {
        // すべての設定をデータベースから取得してスナップショットを作成
        let rows = try await database.query("SELECT key, value FROM config")
        var values: [AbsoluteConfigKey: ConfigValue] = [:]

        for row in rows {
            guard let key = row["key"] as? String,
                  let value = row["value"] as? String else {
                continue
            }

            let configKey = AbsoluteConfigKey(key.split(separator: ".").map(String.init))
            values[configKey] = .string(value)
        }

        return ConfigSnapshot(values: values)
    }

    // その他のメソッド実装...
}

ConfigProviderプロトコルを実装するだけで、既存のプロバイダーと同じように使用できます。

let config = ConfigReader(providers: [
    DatabaseConfigProvider(database: db),
    InMemoryProvider(values: defaults)
])

リーダー(Reader)

ConfigReaderは、プロバイダーへのアクセスを統一的に提供するインターフェースです。アプリケーションのエントリーポイントで一度ConfigReaderを初期化すれば、アプリケーション全体で同じインスタンスを使用して設定にアクセスできます。

@main
struct MyApp {
    static func main() async throws {
        // エントリーポイントで一度だけ初期化
        let config = ConfigReader(providers: [
            EnvironmentVariablesProvider(),
            try await JSONProvider(filePath: "/etc/config.json"),
            InMemoryProvider(values: defaults)
        ])

        // アプリケーション全体で同じインスタンスを使用
        let server = HTTPServer(config: config)
        let database = DatabasePool(config: config)
        let logger = LoggingSystem(config: config)

        try await server.run()
    }
}

ConfigReaderは、型安全なアクセスメソッドを提供します。文字列、整数、浮動小数点数、真偽値、配列など、さまざまな型の設定値を適切な型で取得できます。

// 型ごとの専用メソッド
let host: String = config.string(forKey: "server.host", default: "localhost")
let port: Int = config.int(forKey: "server.port", default: 8080)
let timeout: Double = config.double(forKey: "server.timeout", default: 30.0)
let debug: Bool = config.bool(forKey: "app.debug", default: false)
let hosts: [String] = config.array(forKey: "allowed.hosts", default: [])

型変換が失敗した場合の処理も、3つのパターンから選択できます:

// 1. オプショナル値: 失敗したら nil
let port: Int? = config.int(forKey: "server.port")
if let port = port {
    print("Port: \(port)")
} else {
    print("Port configuration not found or invalid")
}

// 2. デフォルト値: 失敗したらデフォルトを使用
let port = config.int(forKey: "server.port", default: 8080)
print("Port: \(port)") // 必ず値がある

// 3. 必須値: 失敗したらエラーをスロー
do {
    let port = try config.requireInt(forKey: "server.port")
    print("Port: \(port)")
} catch {
    print("Required configuration 'server.port' is missing!")
    throw error
}

スコープ機能も重要な特徴です。特定のプレフィックスを持つ設定グループに繰り返しアクセスする場合、スコープを作成することでコードを簡潔にできます。

// スコープなし: 毎回フルキーを指定
let serverHost = config.string(forKey: "http.server.host", default: "0.0.0.0")
let serverPort = config.int(forKey: "http.server.port", default: 8080)
let serverTimeout = config.double(forKey: "http.server.timeout", default: 30.0)
let serverMaxConnections = config.int(forKey: "http.server.max_connections", default: 100)

// スコープあり: プレフィックスを一度だけ指定
let serverConfig = config.scoped(to: "http.server")
let host = serverConfig.string(forKey: "host", default: "0.0.0.0")
let port = serverConfig.int(forKey: "port", default: 8080)
let timeout = serverConfig.double(forKey: "timeout", default: 30.0)
let maxConnections = serverConfig.int(forKey: "max_connections", default: 100)

スコープは入れ子にすることもできます:

let httpConfig = config.scoped(to: "http")
let serverConfig = httpConfig.scoped(to: "server")
let clientConfig = httpConfig.scoped(to: "client")

// serverConfig は "http.server.*" にアクセス
let serverPort = serverConfig.int(forKey: "port", default: 8080)
// clientConfig は "http.client.*" にアクセス
let clientTimeout = clientConfig.double(forKey: "timeout", default: 60.0)

これにより、関連する設定をグループ化して扱うことができ、コードの可読性が向上します。

階層(Hierarchy)

複数のプロバイダーを組み合わせる際、それらには明確な優先順位があります。ConfigReaderは、プロバイダーを配列として受け取り、その順序で問い合わせを行います。最初に値を返したプロバイダーの値が採用され、それ以降のプロバイダーは問い合わせされません。

let config = ConfigReader(providers: [
    provider1,  // 最優先
    provider2,  // provider1 に値がなければこれを使用
    provider3,  // provider1, provider2 に値がなければこれを使用
    provider4   // 最後のフォールバック
])

この仕組みにより、「コマンドライン引数は環境変数を上書きし、環境変数は設定ファイルを上書きし、設定ファイルはデフォルト値を上書きする」といった階層的な設定管理が自然に実現できます。

実際の例を見てみましょう:

// config.json
// {
//   "server": {
//     "port": 8080,
//     "host": "127.0.0.1"
//   }
// }

// 環境変数
// SERVER_PORT=9000

// コマンドライン引数
// ./myapp --server-host=0.0.0.0

let config = ConfigReader(providers: [
    CommandLineArgumentsProvider(),                    // 1
    EnvironmentVariablesProvider(),                    // 2
    try await JSONProvider(filePath: "config.json"),   // 3
    InMemoryProvider(values: ["server.port": 3000])    // 4
])

let port = config.int(forKey: "server.port", default: 8080)
// 結果: 9000
// 理由: 環境変数 SERVER_PORT=9000 が設定されているため(優先度2)

let host = config.string(forKey: "server.host", default: "localhost")
// 結果: "0.0.0.0"
// 理由: コマンドライン引数 --server-host=0.0.0.0 が指定されているため(優先度1)

階層的な設定管理の利点は、環境ごとに異なる設定戦略を柔軟に実装できることです。

// 開発環境: ローカルファイルを優先
func createDevelopmentConfig() async throws -> ConfigReader {
    ConfigReader(providers: [
        CommandLineArgumentsProvider(),  // デバッグ時の上書き用
        try await JSONProvider(filePath: "config.dev.json"),
        InMemoryProvider(values: developmentDefaults)
    ])
}

// 本番環境: 環境変数を優先
func createProductionConfig() async throws -> ConfigReader {
    ConfigReader(providers: [
        EnvironmentVariablesProvider(
            secretsSpecifier: .pattern(".*SECRET.*|.*PASSWORD.*|.*KEY.*")
        ),
        try await ReloadingJSONProvider(
            filePath: "/etc/myapp/config.json",
            pollInterval: .seconds(30)
        ),
        InMemoryProvider(values: productionDefaults)
    ])
}

また、デバッグ時にコマンドライン引数で特定の設定だけを一時的に変更することも容易です:

# 通常の実行
./myapp

# デバッグモードを有効にして実行
./myapp --app-debug=true

# 特定のAPIエンドポイントに接続
./myapp --api-endpoint=https://staging-api.example.com

スナップショット(Snapshot)

スナップショットは、特定の時点での設定状態を保持する不変なオブジェクトです。複数の設定値に一貫してアクセスする必要がある場合、スナップショットを使用することで、設定が途中で変更されても、すべての値が同じ時点のものであることが保証されます。

なぜこれが重要なのでしょうか?例を見てみましょう:

// スナップショットを使わない場合
func connectToDatabase(config: ConfigReader) async throws {
    let host = config.string(forKey: "database.host", default: "localhost")

    // ここで設定ファイルが更新されたとする
    // (ReloadingJSONProvider を使用している場合)

    let port = config.int(forKey: "database.port", default: 5432)

    // host は古い設定、port は新しい設定になってしまう!
    // 整合性のない接続情報になる可能性がある
    try await Database.connect(host: host, port: port)
}

このような問題を防ぐために、スナップショットを使用します:

// スナップショットを使う場合
func connectToDatabase(config: ConfigReader) async throws {
    // 現時点のスナップショットを取得
    let snapshot = try await config.snapshot()

    // スナップショット内のすべての値は同じ時点のもの
    let host = snapshot.string(forKey: "database.host", default: "localhost")
    let port = snapshot.int(forKey: "database.port", default: 5432)
    let database = snapshot.string(forKey: "database.name", default: "myapp")
    let username = snapshot.string(forKey: "database.username", default: "user")
    let password = snapshot.string(forKey: "database.password", isSecret: true)

    // 設定ファイルが途中で更新されても、すべて同じ時点の値
    try await Database.connect(
        host: host,
        port: port,
        database: database,
        username: username,
        password: password
    )
}

スナップショットは、特にマルチスレッド環境で有用です。複数のスレッドが同時に設定にアクセスする場合、スナップショットを使用することで、各スレッドが一貫した設定状態を見ることができます。

// マルチスレッド環境での使用例
actor ConfigurationManager {
    let config: ConfigReader
    private var currentSnapshot: ConfigSnapshot?

    init(config: ConfigReader) {
        self.config = config
    }

    func getSnapshot() async throws -> ConfigSnapshot {
        if let cached = currentSnapshot {
            return cached
        }

        let snapshot = try await config.snapshot()
        currentSnapshot = snapshot
        return snapshot
    }

    func refreshSnapshot() async throws {
        currentSnapshot = try await config.snapshot()
    }
}

// 使用例
let manager = ConfigurationManager(config: config)

// 複数のタスクが同じスナップショットを参照
await withTaskGroup(of: Void.self) { group in
    let snapshot = try await manager.getSnapshot()

    group.addTask {
        let serverConfig = ServerConfiguration(snapshot: snapshot)
        try await startHTTPServer(config: serverConfig)
    }

    group.addTask {
        let dbConfig = DatabaseConfiguration(snapshot: snapshot)
        try await startDatabasePool(config: dbConfig)
    }

    group.addTask {
        let cacheConfig = CacheConfiguration(snapshot: snapshot)
        try await startCacheManager(config: cacheConfig)
    }
}

また、パフォーマンスの観点からも、複数の値にアクセスする場合、スナップショットを一度取得してからアクセスする方が、プロバイダーへの問い合わせ回数を減らせるため効率的です。

// 非効率: 各アクセスでプロバイダーを問い合わせ
let host = config.string(forKey: "server.host", default: "0.0.0.0")
let port = config.int(forKey: "server.port", default: 8080)
let timeout = config.double(forKey: "server.timeout", default: 30.0)
let maxConn = config.int(forKey: "server.max_connections", default: 100)
// プロバイダーへの問い合わせ: 4回

// 効率的: スナップショットを一度取得
let snapshot = try await config.snapshot()
let host = snapshot.string(forKey: "server.host", default: "0.0.0.0")
let port = snapshot.int(forKey: "server.port", default: 8080)
let timeout = snapshot.double(forKey: "server.timeout", default: 30.0)
let maxConn = snapshot.int(forKey: "server.max_connections", default: 100)
// プロバイダーへの問い合わせ: 1回(snapshot作成時のみ)

4. クイックスタート

このセクションでは、swift-configurationを実際に使い始めるための手順を、ステップバイステップで説明します。インストールから、最初の動作確認、そして基本的な設定読み取りまでを順に見ていきましょう。

インストール

swift-configurationを使い始めるには、まずSwift Packageとしてプロジェクトに追加する必要があります。Package.swiftファイルを開き、dependenciesセクションに以下を追加します。

dependencies: [
    .package(
        url: "https://github.com/apple/swift-configuration",
        .upToNextMinor(from: "0.1.0")
    )
]

バージョン指定には.upToNextMinorを使用することを強く推奨します。swift-configurationはまだバージョン1.0に達しておらず、マイナーバージョン間でAPI破壊的変更が発生する可能性があるためです。この指定により、パッチバージョンの更新(0.1.0 → 0.1.1)は自動的に取り込まれますが、マイナーバージョンの更新(0.1.0 → 0.2.0)は明示的に行う必要があります。

次に、使用するターゲットのdependenciesConfigurationプロダクトを追加します。

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "Configuration", package: "swift-configuration")
    ]
)

完全なPackage.swiftの例は以下のようになります:

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "YourApp",
    platforms: [
        .macOS(.v13)
    ],
    dependencies: [
        .package(
            url: "https://github.com/apple/swift-configuration",
            .upToNextMinor(from: "0.1.0")
        )
    ],
    targets: [
        .executableTarget(
            name: "YourApp",
            dependencies: [
                .product(name: "Configuration", package: "swift-configuration")
            ]
        )
    ]
)

これで基本的なインストールは完了です。追加の機能(YAML サポート、コマンドライン引数サポートなど)が必要な場合は、後述するPackage Traitsを使用して、必要な機能のみを選択的に有効化できます。

最小構成での動作例

インストールが完了したら、最もシンプルな使用例から始めましょう。以下のコードは、メモリ内にデフォルト値を保持し、それらを読み取る最小限の例です。

Sources/YourApp/main.swiftを作成し、以下のコードを記述します:

import Configuration

@main
struct App {
    static func main() async throws {
        // 設定リーダーを作成
        let config = ConfigReader(
            providers: [
                InMemoryProvider(values: [
                    "app.name": "MyApp",
                    "app.port": 8080,
                    "app.debug": true,
                    "app.version": "1.0.0"
                ])
            ]
        )

        // 設定値を読み取る
        let appName = config.string(forKey: "app.name", default: "DefaultApp")
        let port = config.int(forKey: "app.port", default: 3000)
        let debug = config.bool(forKey: "app.debug", default: false)
        let version = config.string(forKey: "app.version", default: "0.0.0")

        // 設定値を使用
        print("=== Application Configuration ===")
        print("Name:    \(appName)")
        print("Version: \(version)")
        print("Port:    \(port)")
        print("Debug:   \(debug ? "enabled" : "disabled")")
        print("================================")

        print("\nStarting \(appName) v\(version) on port \(port)...")
        print("Debug mode: \(debug ? "ON" : "OFF")")
    }
}

このコードを実行すると、以下のような出力が得られます:

=== Application Configuration ===
Name:    MyApp
Version: 1.0.0
Port:    8080
Debug:   enabled
================================

Starting MyApp v1.0.0 on port 8080...
Debug mode: ON

この例では、InMemoryProviderを使用して設定値をメモリ内に保持しています。ConfigReaderを通じてこれらの値にアクセスする際、型安全な方法で適切な型として取得できます。

デフォルト値を指定しているため、設定が見つからない場合でも安全に動作します:

// 存在しないキーにアクセス
let timeout = config.int(forKey: "app.timeout", default: 60)
// 結果: 60 (デフォルト値が使用される)

基本的な設定読み取り

swift-configurationは、さまざまなデータ型の設定値をサポートしています。それぞれの型に対して専用のアクセスメソッドが用意されており、型変換は自動的に行われます。実際のアプリケーションでの使用例を見てみましょう。

文字列型の設定

文字列型の設定は、最も基本的で頻繁に使用されるデータ型です。ホスト名、APIエンドポイント、ログファイルパスなど、さまざまな用途に使用されます。

let config = ConfigReader(providers: [
    InMemoryProvider(values: [
        "server.host": "0.0.0.0",
        "api.endpoint": "https://api.example.com",
        "logging.file": "/var/log/myapp.log"
    ])
])

let host = config.string(forKey: "server.host", default: "localhost")
let apiEndpoint = config.string(forKey: "api.endpoint", default: "https://api.example.com")
let logFile = config.string(forKey: "logging.file", default: "/tmp/app.log")

print("Server host: \(host)")
print("API endpoint: \(apiEndpoint)")
print("Log file: \(logFile)")

整数型の設定

整数型の設定は、ポート番号、タイムアウト値(秒単位)、リトライ回数、接続プールサイズなどに使用されます。

let config = ConfigReader(providers: [
    InMemoryProvider(values: [
        "server.port": 8080,
        "server.max_connections": 100,
        "retry.max_attempts": 3,
        "cache.ttl_seconds": 3600
    ])
])

let port = config.int(forKey: "server.port", default: 8080)
let maxConnections = config.int(forKey: "server.max_connections", default: 100)
let maxRetries = config.int(forKey: "retry.max_attempts", default: 5)
let cacheTTL = config.int(forKey: "cache.ttl_seconds", default: 300)

print("Listening on port \(port)")
print("Max connections: \(maxConnections)")
print("Max retry attempts: \(maxRetries)")
print("Cache TTL: \(cacheTTL) seconds")

浮動小数点数の設定

浮動小数点数は、タイムアウト値(秒単位で小数を含む)、バックオフ係数、レート制限などの設定に適しています。

let config = ConfigReader(providers: [
    InMemoryProvider(values: [
        "server.timeout": 30.5,
        "retry.backoff_multiplier": 1.5,
        "rate_limit.requests_per_second": 10.5
    ])
])

let timeout = config.double(forKey: "server.timeout", default: 30.0)
let backoffMultiplier = config.double(forKey: "retry.backoff_multiplier", default: 2.0)
let rateLimit = config.double(forKey: "rate_limit.requests_per_second", default: 10.0)

print("Request timeout: \(timeout) seconds")
print("Backoff multiplier: \(backoffMultiplier)x")
print("Rate limit: \(rateLimit) req/sec")

真偽値の設定

真偽値は、機能フラグ、デバッグモードの切り替え、機能の有効/無効化などに使用されます。

let config = ConfigReader(providers: [
    InMemoryProvider(values: [
        "app.debug": true,
        "features.new_api": false,
        "logging.verbose": true,
        "cache.enabled": true
    ])
])

let debug = config.bool(forKey: "app.debug", default: false)
let newAPIEnabled = config.bool(forKey: "features.new_api", default: false)
let verboseLogging = config.bool(forKey: "logging.verbose", default: false)
let cacheEnabled = config.bool(forKey: "cache.enabled", default: true)

if debug {
    print("DEBUG MODE: ON")
}

if newAPIEnabled {
    print("New API feature is enabled")
} else {
    print("Using legacy API")
}

if verboseLogging {
    print("Verbose logging enabled")
}

if cacheEnabled {
    print("Cache is enabled")
}

配列型の設定

配列型の設定は、許可されたホストのリスト、タグ、許可されたIPアドレスなどに使用できます。

let config = ConfigReader(providers: [
    InMemoryProvider(values: [
        "server.allowed_hosts": ["example.com", "api.example.com", "www.example.com"],
        "app.tags": ["production", "web", "api"],
        "security.allowed_ips": ["192.168.1.0/24", "10.0.0.0/8"]
    ])
])

let allowedHosts = config.array(forKey: "server.allowed_hosts", default: [])
let tags = config.array(forKey: "app.tags", default: [])
let allowedIPs = config.array(forKey: "security.allowed_ips", default: [])

print("Allowed hosts:")
for host in allowedHosts {
    print("  - \(host)")
}

print("\nApplication tags: \(tags.joined(separator: ", "))")

print("\nAllowed IP ranges:")
for ip in allowedIPs {
    print("  - \(ip)")
}

実践的な例: HTTPサーバーの設定

これらの型を組み合わせて、実際のHTTPサーバーを設定する例を見てみましょう:

import Configuration

struct HTTPServerConfiguration {
    let host: String
    let port: Int
    let timeout: Double
    let maxConnections: Int
    let enableSSL: Bool
    let allowedHosts: [String]

    init(config: ConfigReader) {
        let serverConfig = config.scoped(to: "http.server")

        self.host = serverConfig.string(forKey: "host", default: "0.0.0.0")
        self.port = serverConfig.int(forKey: "port", default: 8080)
        self.timeout = serverConfig.double(forKey: "timeout", default: 30.0)
        self.maxConnections = serverConfig.int(forKey: "max_connections", default: 100)
        self.enableSSL = serverConfig.bool(forKey: "ssl.enabled", default: false)
        self.allowedHosts = serverConfig.array(forKey: "allowed_hosts", default: [])
    }

    func printConfiguration() {
        print("=== HTTP Server Configuration ===")
        print("Host: \(host)")
        print("Port: \(port)")
        print("Timeout: \(timeout) seconds")
        print("Max Connections: \(maxConnections)")
        print("SSL: \(enableSSL ? "enabled" : "disabled")")
        if !allowedHosts.isEmpty {
            print("Allowed Hosts:")
            for host in allowedHosts {
                print("  - \(host)")
            }
        }
        print("=================================")
    }
}

@main
struct App {
    static func main() async throws {
        let config = ConfigReader(providers: [
            InMemoryProvider(values: [
                "http.server.host": "0.0.0.0",
                "http.server.port": 8080,
                "http.server.timeout": 30.0,
                "http.server.max_connections": 100,
                "http.server.ssl.enabled": false,
                "http.server.allowed_hosts": ["example.com", "api.example.com"]
            ])
        ])

        let serverConfig = HTTPServerConfiguration(config: config)
        serverConfig.printConfiguration()

        // サーバーを起動...
    }
}

これらのメソッドはすべて、設定が見つからない場合や型変換に失敗した場合に、指定されたデフォルト値を返します。デフォルト値を指定せずにオプショナル値として取得することも、エラーをスローさせることも可能です。この柔軟性により、アプリケーションの要件に応じた適切なエラーハンドリングを実装できます。

// オプショナル値として取得
if let customPort = config.int(forKey: "custom.port") {
    print("Custom port configured: \(customPort)")
} else {
    print("Custom port not configured, using default")
}

// 必須値として取得(エラーをスロー)
do {
    let apiKey = try config.requireString(forKey: "api.key")
    print("API key: \(apiKey)")
} catch {
    print("ERROR: API key is required but not configured!")
    throw error
}

プロバイダー詳細ガイド

swift-configurationの真の力は、さまざまなプロバイダーを組み合わせて使用できる点にあります。このセクションでは、各プロバイダーの特徴、初期化方法、そして実践的な使用例を詳しく解説します。

EnvironmentVariablesProvider

EnvironmentVariablesProviderは、システムの環境変数から設定を読み取ります。このプロバイダーの最も重要な特徴は、ドット記法のキーを自動的に環境変数の命名規則に変換することです。

キー変換ルール

設定キーは、以下のルールで環境変数名に変換されます:

  • ドット(.)はアンダースコア(_)に変換
  • すべて大文字に変換
// 設定キー → 環境変数名
"http.serverTimeout""HTTP_SERVER_TIMEOUT"
"database.connection.host""DATABASE_CONNECTION_HOST"
"api.key""API_KEY"

これにより、アプリケーションコードでは読みやすいドット記法を使いながら、環境変数では標準的な命名規則に従うことができます。

基本的な使用方法

// 環境変数から読み取る
let config = ConfigReader(providers: [
    EnvironmentVariablesProvider()
])

// DATABASE_HOST=postgres.example.com
// DATABASE_PORT=5432
let dbHost = config.string(forKey: "database.host", default: "localhost")
let dbPort = config.int(forKey: "database.port", default: 5432)

print("Connecting to \(dbHost):\(dbPort)")

.envファイルからの読み込み

開発環境では、.envファイルから環境変数を読み込むことができます。これにより、チーム間で設定を共有しやすくなり、バージョン管理から除外すべき機密情報を安全に管理できます。

# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=myapp_dev
DATABASE_USER=developer
DATABASE_PASSWORD=devpass123
API_KEY=sk_test_abc123
let provider = try await EnvironmentVariablesProvider(
    environmentFilePath: ".env"
)

let config = ConfigReader(providers: [provider])

let dbConfig = [
    "host": config.string(forKey: "database.host", default: "localhost"),
    "port": config.int(forKey: "database.port", default: 5432),
    "name": config.string(forKey: "database.name", default: "myapp"),
    "user": config.string(forKey: "database.user", default: "user")
]

print("Database configuration loaded from .env file")

初期化パラメータ

EnvironmentVariablesProviderは、いくつかの初期化パラメータをサポートしています。

let provider = EnvironmentVariablesProvider(
    secretsSpecifier: .keys(["API_KEY", "DATABASE_PASSWORD", "JWT_SECRET"]),
    bytesDecoder: .base64,
    arraySeparator: ","
)

パラメータの説明:

  • secretsSpecifier: シークレットとして扱う環境変数を指定。ログに出力される際に自動的に<redacted>として編集されます。
  • bytesDecoder: バイト配列データのデコード方式(.base64または.hex
  • arraySeparator: 配列要素の区切り文字(デフォルトは,

シークレットの指定

シークレットは、キーを個別に指定する方法と、正規表現パターンで指定する方法があります。

// 個別のキーを指定
let provider1 = EnvironmentVariablesProvider(
    secretsSpecifier: .keys(["API_KEY", "DATABASE_PASSWORD"])
)

// パターンマッチング
let provider2 = EnvironmentVariablesProvider(
    secretsSpecifier: .pattern(".*SECRET.*|.*PASSWORD.*|.*KEY.*|.*TOKEN.*")
)

パターンマッチングを使用すると、命名規則に従った環境変数を自動的にシークレットとして扱えます。

配列の扱い

環境変数で配列を表現する場合、カンマ区切りの文字列を使用します。

# 環境変数
ALLOWED_HOSTS=example.com,api.example.com,www.example.com
CORS_ORIGINS=http://localhost:3000,https://app.example.com
let config = ConfigReader(providers: [
    EnvironmentVariablesProvider(arraySeparator: ",")
])

let allowedHosts = config.array(forKey: "allowed.hosts", default: [])
// 結果: ["example.com", "api.example.com", "www.example.com"]

実践的な使用例

Dockerコンテナでの使用例を見てみましょう。

# Dockerfile
FROM swift:5.9
WORKDIR /app
COPY . .
RUN swift build -c release

# 環境変数を設定
ENV HTTP_HOST=0.0.0.0
ENV HTTP_PORT=8080
ENV DATABASE_HOST=postgres
ENV DATABASE_PORT=5432

CMD [".build/release/MyApp"]
// アプリケーションコード
@main
struct MyApp {
    static func main() async throws {
        let config = ConfigReader(providers: [
            EnvironmentVariablesProvider(
                secretsSpecifier: .pattern(".*PASSWORD.*|.*SECRET.*|.*KEY.*")
            )
        ])

        let httpHost = config.string(forKey: "http.host", default: "127.0.0.1")
        let httpPort = config.int(forKey: "http.port", default: 8080)

        print("Starting server on \(httpHost):\(httpPort)")

        let dbHost = config.string(forKey: "database.host", default: "localhost")
        let dbPort = config.int(forKey: "database.port", default: 5432)

        print("Connecting to database at \(dbHost):\(dbPort)")
    }
}

Kubernetesでの使用:

# kubernetes-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        env:
        - name: HTTP_HOST
          value: "0.0.0.0"
        - name: HTTP_PORT
          value: "8080"
        - name: DATABASE_HOST
          valueFrom:
            secretKeyRef:
              name: database-secret
              key: host
        - name: DATABASE_PASSWORD
          valueFrom:
            secretKeyRef:
              name: database-secret
              key: password

このように、EnvironmentVariablesProviderは、コンテナ化されたアプリケーションやクラウドネイティブな環境で非常に有用です。

CommandLineArgumentsProvider

CommandLineArgumentsProviderは、コマンドライン引数を解析して設定値を提供します。このプロバイダーは、CLIツールやアプリケーションの実行時パラメータ指定に特に有用です。

フラグ変換ルール

設定キーは、以下のルールでコマンドラインフラグに変換されます:

  • ドット(.)はハイフン(-)に変換
  • キャメルケースはケバブケースに変換
  • 先頭に--が追加される
// 設定キー → コマンドラインフラグ
"http.serverTimeout""--http-server-timeout"
"database.maxConnections""--database-max-connections"
"api.key""--api-key"

サポートされる引数形式

CommandLineArgumentsProviderは、さまざまな引数形式をサポートしています。

# キー=値 形式
./myapp --port=8080 --host=0.0.0.0

# キー 値 形式(スペース区切り)
./myapp --port 8080 --host 0.0.0.0

# 真偽値フラグ
./myapp --debug
./myapp --no-debug

# 配列(カンマ区切り)
./myapp --allowed-hosts=example.com,api.example.com

# 複数の設定を組み合わせ
./myapp --port=9000 --debug --allowed-hosts=localhost,127.0.0.1

基本的な使用方法

import Configuration

@main
struct MyApp {
    static func main() async throws {
        // コマンドライン引数をパース
        let config = ConfigReader(providers: [
            CommandLineArgumentsProvider(),
            InMemoryProvider(values: [
                "port": 8080,
                "host": "localhost",
                "debug": false
            ])
        ])

        let port = config.int(forKey: "port", default: 8080)
        let host = config.string(forKey: "host", default: "localhost")
        let debug = config.bool(forKey: "debug", default: false)

        print("Port: \(port)")
        print("Host: \(host)")
        print("Debug: \(debug)")
    }
}

実行例:

# デフォルト設定で実行
$ ./MyApp
Port: 8080
Host: localhost
Debug: false

# ポートを上書き
$ ./MyApp --port=9000
Port: 9000
Host: localhost
Debug: false

# デバッグモードを有効化
$ ./MyApp --debug
Port: 8080
Host: localhost
Debug: true

# 複数の設定を上書き
$ ./MyApp --port=9000 --host=0.0.0.0 --debug
Port: 9000
Host: 0.0.0.0
Debug: true

CLIツールでの実践例

実際のCLIツールでの使用例を見てみましょう。

import Configuration
import Foundation

@main
struct DataMigrationTool {
    static func main() async throws {
        let config = ConfigReader(providers: [
            CommandLineArgumentsProvider(
                secretsSpecifier: .keys(["database-password", "api-key"])
            ),
            InMemoryProvider(values: [
                "source.database.host": "localhost",
                "source.database.port": 5432,
                "target.database.host": "localhost",
                "target.database.port": 5433,
                "batch.size": 1000,
                "dry.run": false,
                "verbose": false
            ])
        ])

        let sourceHost = config.string(forKey: "source.database.host", default: "localhost")
        let sourcePort = config.int(forKey: "source.database.port", default: 5432)
        let targetHost = config.string(forKey: "target.database.host", default: "localhost")
        let targetPort = config.int(forKey: "target.database.port", default: 5433)
        let batchSize = config.int(forKey: "batch.size", default: 1000)
        let dryRun = config.bool(forKey: "dry.run", default: false)
        let verbose = config.bool(forKey: "verbose", default: false)

        print("=== Data Migration Tool ===")
        print("Source: \(sourceHost):\(sourcePort)")
        print("Target: \(targetHost):\(targetPort)")
        print("Batch size: \(batchSize)")
        print("Mode: \(dryRun ? "DRY RUN" : "LIVE")")
        print("Verbose: \(verbose)")
        print("===========================\n")

        if verbose {
            print("Starting migration with verbose logging...")
        }

        // マイグレーション処理...
    }
}

使用例:

# デフォルト設定で実行(ドライラン)
$ ./DataMigrationTool --dry-run

# 本番実行
$ ./DataMigrationTool \
    --source-database-host=prod-db-1.example.com \
    --source-database-port=5432 \
    --target-database-host=prod-db-2.example.com \
    --target-database-port=5432 \
    --batch-size=5000 \
    --verbose

# ローカルテスト
$ ./DataMigrationTool \
    --source-database-host=localhost \
    --source-database-port=5432 \
    --target-database-host=localhost \
    --target-database-port=5433 \
    --batch-size=100 \
    --verbose \
    --dry-run

ArgumentParserとの組み合わせ

Swift ArgumentParserと組み合わせることで、より洗練されたCLIツールを作成できます。

import ArgumentParser
import Configuration

@main
struct DeployTool: AsyncParsableCommand {
    @Option(name: .long, help: "Environment to deploy to")
    var environment: String = "staging"

    @Option(name: .long, help: "Configuration file path")
    var configFile: String?

    @Flag(name: .long, help: "Enable verbose output")
    var verbose: Bool = false

    mutating func run() async throws {
        // CLIオプションをプロバイダーに変換
        var providers: [ConfigProvider] = []

        // コマンドライン引数から設定を作成
        providers.append(InMemoryProvider(values: [
            "environment": environment,
            "verbose": verbose
        ]))

        // 設定ファイル
        if let configFile = configFile {
            providers.append(try await JSONProvider(filePath: configFile))
        }

        // 環境変数
        providers.append(EnvironmentVariablesProvider())

        // デフォルト設定
        providers.append(InMemoryProvider(values: [
            "deploy.timeout": 300,
            "deploy.retries": 3
        ]))

        let config = ConfigReader(providers: providers)

        // デプロイ処理
        let env = config.string(forKey: "environment", default: "staging")
        let verbose = config.bool(forKey: "verbose", default: false)

        if verbose {
            print("Deploying to \(env) environment...")
        }

        // デプロイロジック...
    }
}

JSONProvider

JSONProviderは、JSONファイルから設定を読み込む静的なプロバイダーです。初期化時に一度ファイルを読み込み、以降は変更を監視しません。

ネストされたオブジェクトの扱い

JSONProviderの重要な特徴は、ネストされたJSONオブジェクトを自動的にドット記法のキーに変換することです。

{
  "http": {
    "server": {
      "host": "0.0.0.0",
      "port": 8080,
      "timeout": 30
    },
    "client": {
      "timeout": 60,
      "retries": 3
    }
  },
  "database": {
    "host": "localhost",
    "port": 5432,
    "pool": {
      "min": 5,
      "max": 20
    }
  }
}

このJSON構造は、以下のキーでアクセスできます:

let config = ConfigReader(providers: [
    try await JSONProvider(filePath: "config.json")
])

// http.server.*
let serverHost = config.string(forKey: "http.server.host", default: "localhost")
let serverPort = config.int(forKey: "http.server.port", default: 8080)
let serverTimeout = config.double(forKey: "http.server.timeout", default: 30.0)

// http.client.*
let clientTimeout = config.double(forKey: "http.client.timeout", default: 60.0)
let clientRetries = config.int(forKey: "http.client.retries", default: 3)

// database.*
let dbHost = config.string(forKey: "database.host", default: "localhost")
let dbPort = config.int(forKey: "database.port", default: 5432)

// database.pool.*
let poolMin = config.int(forKey: "database.pool.min", default: 1)
let poolMax = config.int(forKey: "database.pool.max", default: 10)

基本的な使用方法

// 基本的な読み込み
let config = ConfigReader(providers: [
    try await JSONProvider(filePath: "/etc/myapp/config.json"),
    InMemoryProvider(values: defaults)
])

環境別の設定ファイル

環境ごとに異なる設定ファイルを使用する一般的なパターン:

func createConfig() async throws -> ConfigReader {
    let environment = ProcessInfo.processInfo.environment["ENVIRONMENT"] ?? "development"

    let configPath: String
    switch environment {
    case "production":
        configPath = "/etc/myapp/config.production.json"
    case "staging":
        configPath = "/etc/myapp/config.staging.json"
    default:
        configPath = "config.development.json"
    }

    return ConfigReader(providers: [
        EnvironmentVariablesProvider(),  // 環境変数が最優先
        try await JSONProvider(filePath: configPath),
        InMemoryProvider(values: defaults)
    ])
}

設定ファイルの例:

// config.development.json
{
  "database": {
    "host": "localhost",
    "port": 5432,
    "name": "myapp_dev"
  },
  "logging": {
    "level": "debug"
  },
  "features": {
    "new_api": true
  }
}
// config.production.json
{
  "database": {
    "pool": {
      "min": 10,
      "max": 50
    }
  },
  "logging": {
    "level": "warn"
  },
  "features": {
    "new_api": false
  }
}

ConfigReaderからのパス取得

興味深い機能として、別のConfigReaderから設定ファイルのパスを取得することもできます。これにより、二段階の設定読み込みが可能になります。

// まず環境変数から設定ファイルのパスを取得
let bootstrapConfig = ConfigReader(providers: [
    EnvironmentVariablesProvider()
])

// そのパスを使ってJSONファイルを読み込む
let configPath = bootstrapConfig.string(
    forKey: "config.file.path",
    default: "/etc/myapp/config.json"
)

let mainConfig = ConfigReader(providers: [
    try await JSONProvider(filePath: configPath),
    InMemoryProvider(values: defaults)
])

これにより、環境変数CONFIG_FILE_PATHで設定ファイルの場所を指定できます:

export CONFIG_FILE_PATH=/custom/path/to/config.json
./myapp

YAMLProvider

YAMLProviderは、YAML形式の設定ファイルをサポートします。YAMLは、JSONよりも人間が読みやすく、コメントやマルチラインテキストなどの機能を提供します。

YAMLの利点

YAMLは、設定ファイルとして以下の利点があります:

  • 可読性: インデントベースの構造で視覚的に理解しやすい
  • コメント: # を使ってコメントを記述できる
  • マルチラインテキスト: 複数行のテキストを自然に表現できる
  • 既存ツールとの親和性: Kubernetes、Docker Compose、CI/CDツールなどで広く使用されている

基本的な使用方法

# config.yaml
http:
  server:
    host: 0.0.0.0
    port: 8080
    timeout: 30
  client:
    timeout: 60
    retries: 3

database:
  host: localhost
  port: 5432
  pool:
    min: 5
    max: 20

# ログ設定
logging:
  level: info
  format: json
  # ログファイルパス
  file: /var/log/myapp.log

# 複数行のテキスト
description: |
  これは複数行の
  説明文です。
  改行が保持されます。
let config = ConfigReader(providers: [
    try await YAMLProvider(filePath: "config.yaml")
])

let serverHost = config.string(forKey: "http.server.host", default: "localhost")
let serverPort = config.int(forKey: "http.server.port", default: 8080)
let logLevel = config.string(forKey: "logging.level", default: "info")
let description = config.string(forKey: "description", default: "")

print("Server: \(serverHost):\(serverPort)")
print("Log level: \(logLevel)")
print("Description:\n\(description)")

YAML特有の機能

YAMLは、JSONにはない便利な機能を提供します。

アンカーとエイリアス を使用した設定の再利用:

# 共通設定を定義
defaults: &defaults
  timeout: 30
  retries: 3

production:
  <<: *defaults  # defaultsを継承
  host: prod.example.com

staging:
  <<: *defaults  # defaultsを継承
  host: staging.example.com
  timeout: 60    # タイムアウトだけ上書き

複数の文書 を1つのファイルに含める:

# config.yaml
---
# Document 1: HTTP設定
http:
  port: 8080
  host: 0.0.0.0

---
# Document 2: Database設定
database:
  host: localhost
  port: 5432

Kubernetesとの統合

YAMLProviderは、Kubernetesの設定ファイルとの互換性が高いため、ConfigMapを直接読み込むことができます。

# kubernetes-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: myapp-config
data:
  config.yaml: |
    http:
      server:
        port: 8080
    database:
      host: postgres-service
      port: 5432
// ConfigMapからマウントされた設定ファイルを読み込む
let config = ConfigReader(providers: [
    EnvironmentVariablesProvider(),  // 環境変数が優先
    try await YAMLProvider(filePath: "/etc/config/config.yaml"),
    InMemoryProvider(values: defaults)
])

InMemoryProvider

InMemoryProviderは、メモリ内に設定値を保持する不変のプロバイダーです。一度作成すると値を変更できないため、予測可能で安全です。

主な用途

InMemoryProviderは、以下のような用途に最適です:

  1. デフォルト値の定義: プロバイダー階層の最下層でフォールバック値を提供
  2. ユニットテスト: 完全に制御された設定環境でテストを実行
  3. コンパイル時設定: ビルド時に決定される値を埋め込む
  4. 設定のオーバーライド: 一時的な設定変更

基本的な使用方法

let config = ConfigReader(providers: [
    InMemoryProvider(
        name: "defaults",  // オプション: デバッグ用の名前
        values: [
            "http.port": 8080,
            "http.host": "0.0.0.0",
            "database.pool.min": 5,
            "database.pool.max": 20,
            "app.name": "MyApp",
            "app.version": "1.0.0"
        ]
    )
])

let port = config.int(forKey: "http.port", default: 8080)
let appName = config.string(forKey: "app.name", default: "App")

デフォルト値としての使用

最も一般的な使用パターンは、プロバイダー階層の最後にデフォルト値を配置することです。

let defaultConfig: [String: ConfigValue] = [
    "http.server.host": "0.0.0.0",
    "http.server.port": 8080,
    "http.server.timeout": 30.0,
    "http.server.max_connections": 100,
    "database.pool.min": 5,
    "database.pool.max": 20,
    "logging.level": "info",
    "features.new_api": false
]

let config = ConfigReader(providers: [
    EnvironmentVariablesProvider(),           // 最優先
    try await JSONProvider(filePath: "config.json"),
    InMemoryProvider(values: defaultConfig)  // フォールバック
])

ユニットテストでの使用

InMemoryProviderは、ユニットテストで特に価値があります。ファイルシステムや環境変数に依存せず、完全に制御された設定状態を作成できます。

import Testing
import Configuration

@Suite("Database Service Tests")
struct DatabaseServiceTests {
    @Test("connects with correct configuration")
    func testDatabaseConnection() async throws {
        // テスト用の設定を作成
        let testConfig = ConfigReader(
            providers: [
                InMemoryProvider(values: [
                    "database.host": "test-db.local",
                    "database.port": 5433,
                    "database.name": "test_database",
                    "database.user": "test_user",
                    "database.pool.min": 1,
                    "database.pool.max": 5
                ])
            ]
        )

        let service = DatabaseService(config: testConfig)

        #expect(service.host == "test-db.local")
        #expect(service.port == 5433)
        #expect(service.databaseName == "test_database")
    }

    @Test("handles missing optional configuration")
    func testOptionalConfiguration() async throws {
        let testConfig = ConfigReader(
            providers: [
                InMemoryProvider(values: [
                    "database.host": "localhost"
                    // port は設定しない
                ])
            ]
        )

        let port = testConfig.int(forKey: "database.port")
        #expect(port == nil)

        let portWithDefault = testConfig.int(forKey: "database.port", default: 5432)
        #expect(portWithDefault == 5432)
    }

    @Test("throws on missing required configuration")
    func testRequiredConfiguration() async throws {
        let testConfig = ConfigReader(
            providers: [
                InMemoryProvider(values: [:])  // 空の設定
            ]
        )

        #expect(throws: ConfigError.self) {
            try testConfig.requireString(forKey: "database.host")
        }
    }
}

ビルド時設定の埋め込み

コンパイル時に決定される値をInMemoryProviderで管理することもできます。

// BuildConfiguration.swift
struct BuildConfiguration {
    static let values: [String: ConfigValue] = [
        "app.name": "MyApp",
        "app.version": "1.0.0",
        "app.build_number": "42",
        "app.build_date": "2025-01-15",
        #if DEBUG
        "app.environment": "development",
        "app.debug": true
        #else
        "app.environment": "production",
        "app.debug": false
        #endif
    ]
}

// main.swift
let config = ConfigReader(providers: [
    EnvironmentVariablesProvider(),
    InMemoryProvider(values: BuildConfiguration.values)
])

MutableInMemoryProvider

変更可能なバリアントも存在します(ただし、通常は不変のInMemoryProviderを推奨)。

let mutableProvider = MutableInMemoryProvider(values: [
    "feature.enabled": false
])

let config = ConfigReader(providers: [mutableProvider])

// 初期状態
let enabled = config.bool(forKey: "feature.enabled", default: false)
print(enabled)  // false

// 実行時に値を変更(通常は推奨されない)
mutableProvider.setValue(true, forKey: "feature.enabled")

// 変更後
let updatedEnabled = config.bool(forKey: "feature.enabled", default: false)
print(updatedEnabled)  // true

KeyMappingProvider

KeyMappingProviderは、他のプロバイダーをラップし、キーを変換する特殊なプロバイダーです。このプロバイダー自体は設定値を保持せず、上流のプロバイダーに問い合わせる前にキーを変換する役割を果たします。

主な使用目的

  1. レガシーシステムとの統合: 異なる命名規則への対応
  2. キー名の規則統一: 組織の命名規則に合わせる
  3. 名前空間の追加: キーにプレフィックスを追加
  4. 環境変数のプレフィックス管理: アプリ名をプレフィックスとして追加

基本的な使用方法

let mappedProvider = KeyMappingProvider(
    upstream: EnvironmentVariablesProvider(),
    keyMapper: { key in
        // キー変換ロジック
        print("Original key: \(key.components)")

        // 変換されたキーを返す
        var newComponents = key.components
        // 何らかの変換処理...

        return AbsoluteConfigKey(newComponents, context: key.context)
    }
)

let config = ConfigReader(providers: [mappedProvider])

名前空間の追加

複数のマイクロサービスが同じ環境変数の名前空間を共有している場合、KeyMappingProviderを使用してサービス名をプレフィックスとして追加できます。

// user-service の設定
let namespacedProvider = KeyMappingProvider(
    upstream: EnvironmentVariablesProvider(),
    keyMapper: { key in
        let serviceName = "user-service"
        // "http.port" → "user-service.http.port" → USER_SERVICE_HTTP_PORT
        var components = [serviceName] + key.components
        return AbsoluteConfigKey(components, context: key.context)
    }
)

let config = ConfigReader(providers: [
    namespacedProvider,
    InMemoryProvider(values: defaults)
])

// アプリケーションコードでは通常のキーを使用
let port = config.int(forKey: "http.port", default: 8080)
// 実際には USER_SERVICE_HTTP_PORT 環境変数を探す

環境変数の設定:

# 各サービスごとに名前空間を分ける
export USER_SERVICE_HTTP_PORT=8080
export USER_SERVICE_DATABASE_HOST=user-db

export ORDER_SERVICE_HTTP_PORT=8081
export ORDER_SERVICE_DATABASE_HOST=order-db

レガシーキーのマッピング

既存のシステムから移行する際、古いキー名を新しいキー名にマッピングできます。

// レガシーキー → 新キーのマッピング
let legacyMappings: [String: String] = [
    "server.timeout": "http.server.timeout",
    "server.port": "http.server.port",
    "db.host": "database.host",
    "db.port": "database.port",
    "db.pool.size": "database.pool.max"
]

let legacyProvider = KeyMappingProvider(
    upstream: JSONProvider(filePath: "legacy-config.json"),
    keyMapper: { key in
        let keyString = key.components.joined(separator: ".")

        // マッピングテーブルを確認
        if let newKey = legacyMappings[keyString] {
            let newComponents = newKey.split(separator: ".").map(String.init)
            return AbsoluteConfigKey(newComponents, context: key.context)
        }

        // マッピングがなければそのまま返す
        return key
    }
)

let config = ConfigReader(providers: [
    legacyProvider,
    InMemoryProvider(values: modernDefaults)
])

// 新しいキー名でアクセス
let timeout = config.double(forKey: "http.server.timeout", default: 30.0)
// legacy-config.json の "server.timeout" が読み込まれる

環境別のプレフィックス

環境ごとに異なるプレフィックスを追加する例:

func createEnvironmentConfig(environment: String) -> ConfigReader {
    let envPrefix = environment.uppercased()

    let prefixedProvider = KeyMappingProvider(
        upstream: EnvironmentVariablesProvider(),
        keyMapper: { key in
            // "http.port" → "PRODUCTION_HTTP_PORT"
            var components = [envPrefix] + key.components
            return AbsoluteConfigKey(components, context: key.context)
        }
    )

    return ConfigReader(providers: [
        prefixedProvider,
        InMemoryProvider(values: defaults)
    ])
}

// 使用例
let config = createEnvironmentConfig(environment: "production")

// PRODUCTION_HTTP_PORT 環境変数を探す
let port = config.int(forKey: "http.port", default: 8080)

環境変数:

# 本番環境
export PRODUCTION_HTTP_PORT=8080
export PRODUCTION_DATABASE_HOST=prod-db.example.com

# ステージング環境
export STAGING_HTTP_PORT=8081
export STAGING_DATABASE_HOST=staging-db.example.com

複数のマッピングプロバイダーの組み合わせ

// 1. サービス名をプレフィックスとして追加
let serviceNamespacedProvider = KeyMappingProvider(
    upstream: EnvironmentVariablesProvider(),
    keyMapper: { key in
        var components = ["myapp"] + key.components
        return AbsoluteConfigKey(components, context: key.context)
    }
)

// 2. レガシーキーを新キーにマッピング
let legacyMappedProvider = KeyMappingProvider(
    upstream: JSONProvider(filePath: "legacy.json"),
    keyMapper: { key in
        // レガシーマッピング処理
        return mappedKey
    }
)

let config = ConfigReader(providers: [
    serviceNamespacedProvider,  // MYAPP_HTTP_PORT
    legacyMappedProvider,       // legacy.json から新キー形式で読み取る
    InMemoryProvider(values: defaults)
])

プロバイダー比較表

各プロバイダーの特性を一覧で確認しましょう。

プロバイダー 動的更新 ファイル 適用優先度の推奨 主な用途 Package Trait
EnvironmentVariablesProvider .env 対応 環境別設定、Docker、K8s デフォルト
CommandLineArgumentsProvider - 最高 CLIツール、ランタイムオーバーライド CommandLineArgumentsSupport
JSONProvider .json 静的設定、デフォルト値 JSONSupport (デフォルト)
YAMLProvider .yaml/.yml 可読性重視の設定、K8s YAMLSupport
ReloadingJSONProvider .json 動的設定、ホットリロード JSONSupport + ReloadingSupport
ReloadingYAMLProvider .yaml/.yml 動的設定、ホットリロード YAMLSupport + ReloadingSupport
InMemoryProvider - デフォルト値、テスト デフォルト
KeyMappingProvider - - - キー変換、レガシー統合 デフォルト

まとめ

swift-configurationは、Swiftアプリケーションのための包括的な設定管理ソリューションを提供します。この記事で学んだ主要なポイントを振り返りましょう。

swift-configurationの主な利点

  1. 統一されたAPI: 設定ソースに関わらず一貫したアクセス方法
  2. 階層的管理: 複数のプロバイダーを優先順位付きで組み合わせ
  3. 型安全性: Swiftの型システムを活用した安全な設定アクセス
  4. ホットリロード: ダウンタイムなしで設定を更新
  5. テスト容易性: InMemoryProviderで簡単にテスト可能
  6. セキュリティ: シークレットの自動編集とアクセスログ
  7. モジュール性: Package Traitsで必要な機能のみを選択
  8. 拡張性: カスタムプロバイダーの実装が可能

適切な使用パターン

環境に応じた推奨構成:

開発環境:

ConfigReader(providers: [
    CommandLineArgumentsProvider(),  // デバッグ時の上書き
    try await JSONProvider(filePath: "config.dev.json"),
    InMemoryProvider(values: defaults)
])

ステージング環境:

ConfigReader(providers: [
    EnvironmentVariablesProvider(),
    try await JSONProvider(filePath: "/etc/config.staging.json"),
    InMemoryProvider(values: defaults)
])

本番環境:

ConfigReader(providers: [
    EnvironmentVariablesProvider(
        secretsSpecifier: .pattern(".*SECRET.*|.*PASSWORD.*|.*KEY.*")
    ),
    try await ReloadingJSONProvider(
        filePath: "/etc/config.json",
        pollInterval: .seconds(30)
    ),
    InMemoryProvider(values: defaults)
])

今後の展望

swift-configurationはまだバージョン1.0に達していないため、API変更の可能性には注意が必要です。.upToNextMinor制約を使用して、予期しない破壊的変更を避けることを推奨します。

サーバーサイドSwiftアプリケーションやCLIツールの開発において、swift-configurationは非常に有用なツールです。統一された設定管理により、コードの品質向上、保守性の向上、そして開発効率の向上を実現できます。


参考資料

Discussion