🎉

Lockman - TCAにおける宣言的アクション排他制御の実装

に公開

Lockmanの紹介

TCAアプリケーションの開発において、ボタンの連続タップによる二重送信や、複数の非同期処理の競合といった並行処理制御の課題に直面したことはありませんか。従来のTCAでは、これらの問題に対して手動でキャンセルIDを管理する必要があり、コードの複雑化や保守性の低下を招いていました。

Lockmanは、The Composable Architecture(TCA)における宣言的で型安全なアクション排他制御を実現するライブラリです。

開発者が直面する問題

ユーザー登録フォームで「登録」ボタンをタップし、サーバーに送信後、完了画面に遷移する機能を実装したとします。ユーザーが素早く連続してボタンを2回タップした場合、適切なアクション排他制御がないと以下の問題が発生します:

  • 複数の登録リクエストがサーバーに送信される
  • 同じユーザーが二重登録される
  • 画面遷移が複数回トリガーされる
  • ユーザー体験の混乱とデータの不整合

TCAにおけるアクション排他制御の現状

ユーザー登録フォームから完了画面に遷移するTCAのFeatureの例:

@Reducer
struct UserRegistrationFeature {
  struct State {
    var username: String = ""
    var email: String = ""
    @Presents var destination: Destination.State?
  }

  enum Action {
    case registerButtonTapped
    case registrationCompleted(userId: String)
    case destination(PresentationAction<Destination.Action>)
  }

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .registerButtonTapped:
        return .run { [username = state.username, email = state.email] send in
          // ユーザー登録をサーバーに送信
          let userId = try await apiClient.registerUser(username: username, email: email)
          await send(.registrationCompleted(userId: userId))
        }

      case .registrationCompleted(let userId):
        // 完了画面に遷移
        state.destination = .completed(userId: userId)
        return .none

      case .destination:
        return .none
      }
    }
  }
}

従来の解決策

TCAでは、手動でキャンセルロジックを記述する必要があります:

case .registerButtonTapped:
  return .run { [username = state.username, email = state.email] send in
    let userId = try await apiClient.registerUser(username: username, email: email)
    await send(.registrationCompleted(userId: userId))
  }
  .cancellable(id: CancelID.userRegistration)

この手法には、以下の制限があります:

  • 先行アクションを優先することができない - 後続アクションが先行アクションをキャンセルする一方通行の動作しかできず、先行アクションを優先するような柔軟な制御が難しい
  • キャンセルIDの管理が煩雑 - 各アクションに対して手動でIDを定義・管理する必要がある
  • 複雑なシナリオに対応しづらい - 優先度制御や並行数制限などの高度な制御が困難

宣言的アクション排他制御

Lockmanは、命令的にキャンセルを管理する代わりに、排他制御の要件を宣言的に指定します。

Lockmanで実装した例

@Reducer
struct UserRegistrationFeature {
  struct State {
    var username: String = ""
    var email: String = ""
    @Presents var destination: Destination.State?
  }

  enum Action {
    case view(View)
    case state(State)
    case destination(PresentationAction<Destination.Action>)

    @LockmanSingleExecution
    enum View {
      case registerButtonTapped

      var lockmanInfo: LockmanSingleExecutionInfo {
        // actionNameは@LockmanSingleExecutionマクロによって自動生成されます
        .init(actionId: actionName, mode: .boundary)
      }
    }

    enum State {
      case registrationCompleted(userId: String)
      case showError(String)
    }
  }

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .view(.registerButtonTapped):
        return .run { [username = state.username, email = state.email] send in
          let userId = try await apiClient.registerUser(username: username, email: email)
          await send(.state(.registrationCompleted(userId: userId)))
        }

      case .state(.registrationCompleted(let userId)):
        state.destination = .completed(userId: userId)
        return .none

      case .destination:
        return .none
      }
    }
    .lock(
      boundaryId: CancelID.userRegistration,
      lockFailure: { error, send in
        // ロック失敗時のエラーハンドリングが可能
        if error is LockmanSingleExecutionError {
          await send(.state(.showError("処理中です。しばらくお待ちください")))
        }
      },
      for: \.view
    )
  }
}

この実装により、mode: .boundaryの設定によって後続アクションがキャンセルされるため、連続タップによる二重送信が自動的に防止されます。

実装可能な排他制御パターン

1. 重複実行の防止

重複実行の防止を実装する例:

@LockmanSingleExecution
enum View {
  case submitPayment
  case uploadPhoto
  case sendMessage

  var lockmanInfo: LockmanSingleExecutionInfo {
    .init(actionId: actionName, mode: .boundary)
  }
}

この設定により、アクションの重複実行が防止されます。

2. 優先度ベースの制御

プロフィール画像の選択と更新が同時に発生する場合、更新を優先する実装例:

@LockmanPriorityBased
enum View {
  case selectProfileImage
  case updateProfileWithImage

  var lockmanInfo: LockmanPriorityBasedInfo {
    switch self {
    case .selectProfileImage:
      return .init(actionId: actionName, priority: .low(.replaceable))
    case .updateProfileWithImage:
      return .init(actionId: actionName, priority: .high(.exclusive))
    }
  }
}

この設定により:

  • プロフィール更新が実行中の場合、画像選択をブロック
  • 画像選択中に更新が開始された場合、画像選択をキャンセルして更新を実行
  • 同じ優先度レベル内での詳細な制御を提供

3. 並行数の制限

同時ダウンロードを3つに制限する実装例:

enum ConcurrencyGroup: LockmanConcurrencyGroup {
  case downloads
  var id: String { "downloads" }
  var limit: LockmanConcurrencyLimit { .limited(3) }
}

@LockmanConcurrencyLimited
enum View {
  case downloadFile(URL)
  var lockmanInfo: LockmanConcurrencyLimitedInfo {
    .init(
      actionId: actionName,
      group: ConcurrencyGroup.downloads
    )
  }
}

4. 動的条件

ログイン状態や営業時間などのランタイム条件に基づいて処理を制御する実装例:

@LockmanDynamicCondition
enum View {
  case syncData
  case performMaintenance

  var lockmanInfo: LockmanDynamicConditionInfo {
    LockmanDynamicConditionInfo(actionId: actionName)
  }
}

// Reducerでの使用
var body: some Reducer<State, Action> {
  Reduce { state, action in
    switch action {
    case .view(.syncData):
      return .run { send in
        let data = try await apiClient.syncData()
        await send(.dataSynced(data))
      }
    default:
      return .none
    }
  }
  .lock(
    boundaryId: CancelID.userAction,
    lockFailure: { error, send in
      // ロック失敗の処理
    },
    lockCondition: { state, action in
      guard state.isLoggedIn else {
        return .failure(AuthError.notLoggedIn)
      }
      return .success
    },
    for: \.view
  )
}

5. 複合戦略

複数の戦略を組み合わせる実装例:

@LockmanCompositeStrategy(
  LockmanPriorityBasedStrategy.self,
  LockmanSingleExecutionInfo.self
)
enum View {
  case criticalOperation

  var lockmanInfo: LockmanCompositeInfo2<
    LockmanPriorityBasedInfo,
    LockmanSingleExecutionInfo
  > {
    .init(
      actionId: actionName,
      lockmanInfoForStrategy1: LockmanPriorityBasedInfo(
        actionId: actionName,
        priority: .high(.exclusive)
      ),
      lockmanInfoForStrategy2: LockmanSingleExecutionInfo(
        actionId: actionName,
        mode: .boundary
      )
    )
  }
}

アーキテクチャ

型安全な設計

Lockmanは型システムを活用することで、以下の安全性を提供します。

コンパイル時エラー検出

誤った戦略の組み合わせや設定ミスは、実行前にコンパイルエラーとして検出されます。例えば、@LockmanSingleExecutionを付けたActionにLockmanPriorityBasedInfoを返すと、型の不一致によりコンパイルエラーになります。

ActionとStrategyの型一貫性

各マクロ(@LockmanSingleExecution@LockmanPriorityBasedなど)は、対応するLockmanInfo型を要求します。これにより、ActionとStrategyの組み合わせが常に正しいことが保証されます。

IDE補完によるサポート

型情報により、IDEは利用可能なプロパティやメソッドを正確に提案します。これにより、ドキュメントを参照せずに正しい実装が可能になります。

ストラテジーパターン

各並行処理戦略は独立したStrategyとして実装されています。

  • 新しい戦略の追加が容易 - 新しい排他制御パターンを既存コードに影響を与えずに追加できます
  • 既存のコードに影響を与えずに拡張可能 - Open/Closed原則に準拠し、拡張に対して開いており、修正に対して閉じています
  • 各戦略を独立してテスト可能 - 戦略ごとに独立したテストを記述でき、保守性が向上します

この設計により、プロジェクトの要件に応じて柔軟に戦略を追加・カスタマイズできます。

マクロによる開発者体験

Swift Macrosを活用することで、ボイラープレートコードを大幅に削減しています。

// マクロを使用
@LockmanSingleExecution
enum View {
  case fetchData
  var lockmanInfo: LockmanSingleExecutionInfo {
    // actionNameはマクロによって自動生成されます
    LockmanSingleExecutionInfo(actionId: actionName, mode: .boundary)
  }
}

// マクロなしの手動実装
enum View: LockmanAction {
  case fetchData
  static let strategyId = LockmanSingleExecutionStrategy.makeStrategyId()
  var lockmanInfo: LockmanSingleExecutionInfo {
    .init(actionId: actionName, mode: .boundary)
  }
  // マクロなしの場合はactionNameを手動で実装する必要があります
  var actionName: String {
    switch self {
    case .fetchData: return "fetchData"
    }
  }
}

まとめ

TCAにおけるアクション排他制御の課題に対して、Lockmanは宣言的なアプローチと型安全な設計による解決策を提供します。

主な特徴

  • 宣言的なマクロベースの設定によるボイラープレートの削減
  • 重複実行や競合状態などの並行処理の問題を自動的に防止
  • 優先度ベースおよび複合戦略による柔軟な制御
  • 包括的なロギングと状態検査によるデバッグ支援
  • 既存のコードを壊すことなく段階的に導入可能

命令的なキャンセル管理から宣言的な排他制御への移行により、TCAアプリケーションにおける並行処理の実装が改善されます。

次のステップ

Lockmanに興味を持っていただいた方は、以下のリソースをご活用ください。

フィードバックや質問があれば、GitHubでお待ちしています。


この記事は2025年11月に執筆されました。技術情報は執筆時点のものです。

Discussion