🔮

利用シーンから Dependencies の仕組みを紐解く

2022/12/21に公開約32,200字

この記事は The Composable Architecture Advent Calendar 2022 の 23 日目の記事になります。

TCA に最近導入された Dependencies は魔法のように live, preview, test で Dependency (依存関係) を自動的に切り替えてくれる素晴らしい DI ツールです。
本記事ではその Dependencies の魔法がどのように実現されているかに着目して読み解いた話をまとめようと思います。(ステップバイステップで説明しようとしたら記事が非常に長くなってしまってしまいすみません)

仕組みについての記事になるため、Dependencies そのものについて詳しくは説明しません。Dependencies については TCA の Dependencies のドキュメントや、imajo さんの「TCAにあらかじめ用意されているLibrary Dependenciesについて」などをご参照ください。

この記事では、以下を理解できるようになることを目指しています。

  • TCA において Dependencies がどのように動作しているのか
  • (TCA 関係なく) Dependencies がどのような仕組みになっているのか
  • TaskLocal の実用例

TCA における利用シーン別に Dependencies の仕組みを探る

それでは早速、利用シーン別に Dependencies の仕組みを探っていこうと思います。
仕組みを探るために Dependencies ディレクトリを主に参照します。
ここでいう利用シーンというのは以下を指しています。

  • live
    • 主に実アプリで動作する Dependency
  • test
    • テストで動作する Dependency
  • preview
    • Xcode Previews で動作する Dependency

これらはそれぞれ DependencyContext.swift で定義されているもので、Dependencies の仕組みを使っているのであれば、liveValue, testValue, previewValue という形のものもきっと見かけていると思います。

本記事では主に livetest 環境でどのように Dependencies が動作するかを見ていくことで、仕組みを探っていこうと思います。

live

まずは live について、実際の利用シーンから辿っていく形で見ていこうと思います。

ここでは TCA の Examples にある 02-Effects-Basics.swift から辿っていこうと思います。

このコードでは Dependency を 2 つ利用していて、continuousClockfactClient がそれにあたります。
今回は説明のために factClient に絞って見てみます。まず factClient の利用部分を以下に抜粋します。

02-Effects-Basics.swift
struct EffectsBasics: ReducerProtocol {
  struct State: Equatable {
    // ...
    var count = 0
    var numberFact: String?
  }
  
  enum Action: Equtable {
    // ...
    case numberFactButtonTapped
    case numberFactResponse(TaskResult<String>)
  }
  
  // ...
  @Dependency(\.factClient) var factClient

  func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
    // ...
    case .numberFactButtonTapped:
      // ...
      return .task { [count = state.count] in
        await .numberFactResponse(TaskResult { try await self.factClient.fetch(count) })
      }
    case let .numberFactResponse(.success(response)):
      // ...
    }
  }
}

必要そうな部分のみ抜粋しました。処理の流れをテキストでも説明すると、

  • @Dependency(\.factClient) var factClient という形で Dependency Property Wrapper を利用して factClient を定義
  • numberFactButtonTapped Action のハンドリング部分では、定義した factClientfetch を利用して numberFactResponse を return

Dependencies を使い慣れていれば、特に目立ったものはないシンプルな処理となっています。

しかし、この @Dependency Property Wrapper はどのように動作しているのでしょうか?また、fetch はなぜ利用できるのでしょうか?
その辺りについて見ていくために、Dependency を利用するようになってから人によってはおまじない的に定義しているかもしれない、factClient を Dependency として扱えるようにするためのコードを見てみます。

FactClient.swift
// interface 部分
struct FactClient {
  var fetch: @Sendable (Int) async throws -> String
}

// DependencyValues extension 部分
extension DependencyValues {
  var factClient: FactClient {
    get { self[FactClient.self] }
    set { self[FactClient.self] = newValue }
  }
}

// DependencyKey 準拠部分
extension FactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      try await Task.sleep(nanoseconds: NSEC_PER_SEC)
      let (data, _) = try await URLSession.shared
        .data(from: URL(string: "https://numbersapi.com/\(number)/trivia")!)
      return String(decoding: data, as: UTF8.self)
    }
  )
  
  static let testValue = Self(
    fetch: unimplemented("\(Self.self).fetch")
  )
}

独自の Dependency を利用したい時の実装としては非常にシンプルなものとなっています。
Dependencies を利用できるようにするための上記のコードの手続きは、SwiftUI の EnvironmentValues で custom な EnvironmentValues を利用したい時に行う手続きとよく似ているため、TCA を使ったことがない方でも見覚えがあるかもしれません。

細かい部分には触れませんが、独自の Dependencies を利用するための主要な手続きは以下の 2 つです。

  • Dependency として利用したい FactClientDependencyKey に準拠させる
  • DependencyValues を extension して DependencyKey に準拠させておいた FactClient を利用した computed property を定義する

少しずつ説明していきます。

DependencyKey とは何なのか

まず、「Dependency として利用したい FactClientDependencyKey に準拠させる」部分について見ていきます。
DependencyKey は TCA の中で定義されている protocol で、実装は DependencyKey.swift に存在しています。

ドキュメントコメントがかなり豊富ですが、実装部分だけ抜粋してみると案外シンプルな protocol となっています。

DependencyKey.swift
import XCTestDynamicOverlay

public protocol DependencyKey: TestDependencyKey {
  static var liveValue: Value { get }
  associatedtype Value = Self
  static var previewValue: Value { get }
  static var testValue: Value { get }
}

public protocol TestDependencyKey {
  associatedtype Value = Self
  static var previewValue: Value { get }
  static var testValue: Value { get }
}

extension DependencyKey {
  public static var previewValue: Value { Self.liveValue }
  
  public static var testValue: Value {
    // テストに関わる部分なので一部コードは省略
    // ...
    return Self.liveValue
  }
}

extension TestDependencyKey {
  public static var previewValue: Value { Self.testValue }
}

TestDependencyKey という protocol が存在していて、DependencyKey はその TestDependencyKey に準拠するように定義されていることがわかります。
@Dependency Property Wrapper を利用できるようにするには、最低でも TestDependencyKey に独自の Dependency を準拠させる必要があるのですが、TestDependencyKey では previewValuetestValue のみを最低限実装すれば良い状態となっています。
ただ、TestDependencyKey のみに準拠させた状態で実際のアプリケーションからその Dependency を利用しようとすると、runtime warning が出るようになっているため、実際のアプリケーションで Dependency を利用したい場合は DependencyKey に独自の Dependency を準拠させる必要があります。 (この辺りはさらに詳しく後ほど説明します)

実際に FactClient を DependencyKey にどのように準拠させていたかを振り返ってみます。

FactClient.swift
extension FactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      try await Task.sleep(nanoseconds: NSEC_PER_SEC)
      let (data, _) = try await URLSession.shared
        .data(from: URL(string: "https://numbersapi.com/\(number)/trivia")!)
      return String(decoding: data, as: UTF8.self)
    }
  )
  
  static let testValue = Self(
    fetch: unimplemented("\(Self.self).fetch")
  )
}

DependencyKey の protocol requirements を満たすために liveValuetestValue を提供していることがわかります。
ここまでで、DependencyKey の実体がどんなものかということはある程度理解できたかなと思います。

しかし、まだ @Dependency Property Wrapper がどのように動作するかまでは掴めていません。
そのため、次に @Dependency Property Wrapper 自体の実装を見ていくことにします。

@Dependency Property Wrapper の実装

実は @Dependency Property Wrapper 自体も割とシンプルな実装になっており、Property Wrapper に慣れ親しんだ人なら (慣れ親しんでいない場合はこちらの「Property Wrappers」を参照ください)、特に奇抜だと感じる実装は存在しません。

Dependency.swift
@propertyWrapper
public struct Dependency<Value>: @unchecked Sendable {
  private let keyPath: KeyPath<DependencyValues, Value>
  private let file: StaticString
  private let fileID: StaticString
  private let line: UInt
  
  // KeyPath で initilize できる
  public init(
    _ keyPath: KeyPath<DependencyValues, Value>,
    file: StaticString = #file,
    fileID: StaticString = #fileID,
    line: UInt = #line
  ) {
    self.keyPath = keyPath
    self.file = file
    self.fileID = fileID
    self.line = line
  }
  
  public var wrappedValue: Value {
    #if DEBUG
      var currentDependency = DependencyValues.currentDependency
      currentDependency.file = self.file
      currentDependency.fileID = self.fileID
      currentDependency.line = self.line
      return DependencyValues.$currentDependency.withValue(currentDependency) {
        DependencyValues._current[keyPath: self.keyPath]
      }
    #else
      return DependencyValues._current[keyPath: self.keyPath]
    #endif
  }
}

実装を見てみると、KeyPath<DependencyValues, Value> な KeyPath で initialize できることがわかります。
FactClient の場合、@Dependency(\.factClient) var factClient という形で Property Wrapper を利用できていましたが、実はそれは DependencyValues の extesnion で factClient computed property を以下のように定義していたから可能になっていたという仕組みになっていました。(DependencyValues って何なんだという話はもう少しだけ目を瞑ってください)

FactClient.swift
extension DependencyValues {
  var factClient: FactClient {
    get { self[FactClient.self] }
    set { self[FactClient.self] = newValue }
  }
}

Value は Generic ですが、\.factClient によって KeyPath が KeyPath<DependencyValues, FactClient> として定まります。

ここまでで @Dependency(\.factClient) var factClient という形で @Dependency Property Wrapper を利用できる仕組みは掴めてきたでしょうか?

ではいよいよ一番重ための話である factClient.fetch がなぜ利用できるのかを見ていくことにしようと思います。

factClient.fetch はなぜ利用できるのか?

ここまでで何となくでも「@Dependency Property Wrapper がなぜ定義できるのか」ということを掴んで頂けていたら嬉しいです。
では、なぜ定義できるのかというところは理解したものの、なぜ factClient.fetch が実際にアプリで利用できているかという話について次は探っていくことにします。

何度も登場していますが FactClient.swift では以下のように liveValue、つまり実際にアプリで利用される値を定義していました。

FactClient.swift
extension FactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      try await Task.sleep(nanoseconds: NSEC_PER_SEC)
      let (data, _) = try await URLSession.shared
        .data(from: URL(string: "https://numbersapi.com/\(number)/trivia")!)
      return String(decoding: data, as: UTF8.self)
    }
  )
  // ...
}

しかし、ここで liveValue を定義しているだけで、なぜ実コード上からは

疑似的なコード.swift
@Dependency(\.factClient) var factClient

// ...

factClient.fetch(count)

のような形で、当然のように liveValue で提供した fetch の実装を利用することができるのでしょうか?次は、これがどのように実現されているのかについて見ていくことにします。

まず factClient.fetch(count) という形で factClient にアクセスしていますが、Property Wrapper の性質上、factClient という形で property にアクセスした場合、Property Wrapper の wrappedValue にアクセスすることになります。
ここで、wrappedValue がどんな実装になっていたかを再掲します。

Dependency.swift
@propertyWrapper
public struct Dependency<Value>: @unchecked Sendable {
  // ...
  
  public var wrappedValue: Value {
    #if DEBUG
      var currentDependency = DependencyValues.currentDependency
      currentDependency.file = self.file
      currentDependency.fileID = self.fileID
      currentDependency.line = self.line
      return DependencyValues.$currentDependency.withValue(currentDependency) {
        DependencyValues._current[keyPath: self.keyPath]
      }
    #else
      return DependencyValues._current[keyPath: self.keyPath]
    #endif
  }
}

if #DEBUG で囲まれていて、デバッグ時とそうではない時で wrappedValue の挙動が若干変わるようになっています。
デバッグ時の方の挙動について説明すると話がややこしくなってしまうため、一旦説明を飛ばします。

Debug build ではない時は return DependencyValues._current[keyPath: self.keyPath] という 1 行だけで処理が終わっていることがわかります。
KeyPath を用いて DependencyValues._current という struct の field にアクセスしていることがわかりますが、DependencyValues._current についても少しだけコードを見てみます。

DependencyValues.swift
struct DependencyValues: Sendable {
  @TaskLocal public static var _current = Self()
  // ...
}

DependencyValues 自体は Sendable な struct で、_current はどうやら DependencyValues 自体であることがわかります。(@TaskLocal が利用されていますが、その辺りの話も後ほどします)
つまり、DependencyValues._current[keyPath: self.keyPath] という処理は、DependencyValues struct が持っている特定の field を KeyPath を用いて get しているということになります。

ここで、@Dependency(\.factClient) var factClient を利用して factClient.fetch(count) を呼び出した時のことを考えてみます。
\.factClient という KeyPath を指定しているため、factClient という wrappedValue にアクセスした時点で、その KeyPath を利用した DependencyValues._current[keyPath: self.keyPath] が呼ばれているということになります。
実は DependencyValues には extension として factClient property を定義していました。

FactClient.swift
extension DependencyValues {
  var factClient: FactClient {
    get { self[FactClient.self] }
    set { self[FactClient.self] = newValue }
  }
}

つまり、DependencyValues._current[keyPath: self.keyPath]factClient property の getter に記述されている self[FactClient.self] という subscript が呼ばれることになります。

では DependencyValues で subscript がどのように実装されているかを見ていきます。

DependencyValues.swift
struct DependencyValues: Sendable {
    @TaskLocal public static var _current = Self()
    // ...
  
    public subscript<Key: TestDependencyKey>(
    key: Key.Type,
    file: StaticString = #file,
    function: StaticString = #function,
    line: UInt = #line
  ) -> Key.Value where Key.Value: Sendable {
    get {
      guard let dependency = self.storage[ObjectIdentifier(key)]?.base as? Key.Value
      else {
        let context =
          self.storage[ObjectIdentifier(DependencyContextKey.self)]?.base as? DependencyContext
          ?? defaultContext

        switch context {
        case .live, .preview:
          return self.cachedValues.value(
            for: Key.self,
            context: context,
            file: file,
            function: function,
            line: line
          )
        case .test:
	  // テストは着目しないので省略
        }
      }
      return dependency
    }
    set {
      self.storage[ObjectIdentifier(key)] = AnySendable(newValue)
    }
  }
}

subscript の引数には key: Key.Type を提供するようになっています。
この subscript の実装によって self[FactClient.self] という形のアクセスが可能になっているというわけです。

実際の実装も少しずつ見ていきましょう。

まず、

guard let dependency = self.storage[ObjectIdentifier(key)]?.base as? Key.Value

という guard があります。
ここでは storage という変数に key を利用してアクセスして、Key.Value (KeyTestDependencyKey なので、Value は associatedtype) にアクセスしようと試みています。

storageDependencyValues に定義されている Dictionary になっています。

DependencyValues.swift
public struct DependencyValues: Sendable {
  // ...
  private var storage: [ObjectIdentifier: AnySendable] = [:]

Dictionary の value となっている AnySendable は型消去を行うための単なる wrapper struct です。

DependencyValues.swift
private struct AnySendable: @unchecked Sendable {
  let base: Any
  @inlinable
  init<Base: Sendable>(_ base: Base) {
    self.base = base
  }
}

DependencyValuesstorage には何も格納されていないため、この guard の else の処理に入っていきます。
else の中では、まず storage に対して DependencyContextKey.self を利用して DependencyContext というものを取得しようとしています。

DependencyValues.swift
let context =
          self.storage[ObjectIdentifier(DependencyContextKey.self)]?.base as? DependencyContext
          ?? defaultContext

storage には何も入っていないため、ここでは defaultContext というものが context に格納されることになりますが、DependencyContext というのは実はこの記事の冒頭の方で少しだけ出てきたりはしていたのですが、非常にシンプルな enum となっていて、DependencyContextKey は Dependencies に付属しているいくつかの Dependency のうちの一つとして定義されているものになっています。

では subscript の処理に戻って、context に格納されることになる defaultContext についても見てみます。
defaultContextDependencyValues.swift の中で定義されていて、ProcessInfo によって previewlive どちらかの DependencyContext を返却するようなシンプルな実装になっています。

DependencyValues.swift
private let defaultContext: DependencyContext = {
  if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
    return .preview
  } else {
    return .live
  }
}()

現在は実際のアプリコードを動かしている想定の話をしているので、live が返却されることとなり、context には live が格納されます。

live が格納されているため、残りの subscript の処理では、live の部分の処理が動くことになります。

switch context {
case .live, .preview:
  return self.cachedValues.value(
    for: Key.self,
    context: context,
    file: file,
    function: function,
    line: line
  )
case .test:
  // ...

ここでは cachedValues.value というものに、Key の情報と context などを渡しているようです。
cachedValues についても DependencyValues struct の内部に定義されていて、

DependencyValues.swift
public struct DependencyValues: Sendable {
  // ...
  private var cachedValues = CachedValues()
  
  // ...
}

のように CachedValues という class をインスタンス化する形で定義されています。
CachedValues class についても DependencyValues.swift に定義されています。

DependencyValues.swift
private final class CachedValues: @unchecked Sendable {
  struct CacheKey: Hashable, Sendable {
    let id: ObjectIdentifier
    let context: DependencyContext
  }

  private let lock = NSRecursiveLock()
  private var cached = [CacheKey: AnySendable]()

  func value<Key: TestDependencyKey>(
    for key: Key.Type,
    context: DependencyContext,
    file: StaticString = #file,
    function: StaticString = #function,
    line: UInt = #line
  ) -> Key.Value {
    self.lock.lock()
    defer { self.lock.unlock() }

    let cacheKey = CacheKey(id: ObjectIdentifier(key), context: context)
    guard let value = self.cached[cacheKey]?.base as? Key.Value
    else {
      let value: Key.Value?
      switch context {
      case .live:
        value = _liveValue(key) as? Key.Value
      case .preview:
        value = Key.previewValue
      case .test:
        value = Key.testValue
      }

      guard let value = value
      else {
        if !DependencyValues.isSetting {
          var dependencyDescription = ""
          if let fileID = DependencyValues.currentDependency.fileID,
            let line = DependencyValues.currentDependency.line
          {
            dependencyDescription.append(
              """
                Location:
                  \(fileID):\(line)
              """
            )
          }
          dependencyDescription.append(
            Key.self == Key.Value.self
              ? """
                Dependency:
                  \(typeName(Key.Value.self))
              """
              : """
                Key:
                  \(typeName(Key.self))
                Value:
                  \(typeName(Key.Value.self))
              """
          )

          runtimeWarn(
            """
            "@Dependency(\\.\(function))" has no live implementation, but was accessed from a \
            live context.
            \(dependencyDescription)
            Every dependency registered with the library must conform to "DependencyKey", and \
            that conformance must be visible to the running application.
            To fix, make sure that "\(typeName(Key.self))" conforms to "DependencyKey" by \
            providing a live implementation of your dependency, and make sure that the \
            conformance is linked with this current application.
            """,
            file: DependencyValues.currentDependency.file ?? file,
            line: DependencyValues.currentDependency.line ?? line
          )
        }
        return Key.testValue
      }

      self.cached[cacheKey] = AnySendable(value)
      return value
    }

    return value
  }
}

value という function については、コードが少し長かったり、lock を利用した排他制御を行っていて複雑に見えるかもしれませんが、実は処理は割とシンプルです。

  • まず CachedValues にある cached: [CacheKey: AnySendable] という変数に、該当 Key の Dependency が存在するかを調べます
  • 存在する場合は cached に格納されている値をそのまま返却します
  • 存在しない場合 かつ live の場合は value という変数に _liveValue(key) というものの結果を代入します
    • _liveValueこちらに定義されていて、該当 Key の liveValue を取ってこれるようになっています
  • 仮にその時点で value が nil だった場合 (live の場合は liveValue を実装していない場合) は runtime warning を出すようにするという実装になっています
  • value が nil ではなかった場合は、その値を cached に入れてキャッシュできるような作りになっています

CachedValues の説明が一通り終わったところで、もともと説明していた subscript の処理に戻りますが、

switch context {
case .live, .preview:
  return self.cachedValues.value(
    for: Key.self,
    context: context,
    file: file,
    function: function,
    line: line
  )
case .test:
  // ...

のように cachedValues.value を用いることで、うまく Dependency をキャッシュしつつ良い感じに扱えるようになっているという仕組みを実現できています。
ちなみに live の場合は storage という変数をほとんど利用していませんでしたが、後ほど test の時に活用される場面を見ることができます。

少し省略してしまった部分はありますが、以上が大まかな live Dependencies の実現方法でした。

test

次に test で Dependencies がどのように動くのかも見ていきましょう。

test で説明のために利用する例としては、live の時の説明に使った「02-Effects-Basics.swift」のテストコードである「02-Effects-BasicsTests.swift」を利用しようと思います。

このテストコードの中で FactClient の Dependencies を利用しているコードは以下のようになっています。

02-Effects-BasicsTests.swift
@MainActor
final class EffectsBasicsTests: XCTestCase {
  // ...

  func testNumberFact() async {
    let store = TestStore(
      initialState: EffectsBasics.State(),
      reducer: EffectsBasics()
    )

    store.dependencies.factClient.fetch = { "\($0) is a good number Brent" }
    store.dependencies.continuousClock = ImmediateClock()

    await store.send(.incrementButtonTapped) {
      $0.count = 1
    }
    await store.send(.numberFactButtonTapped) {
      $0.isNumberFactRequestInFlight = true
    }
    await store.receive(.numberFactResponse(.success("1 is a good number Brent"))) {
      $0.isNumberFactRequestInFlight = false
      $0.numberFact = "1 is a good number Brent"
    }
  }
  
  // ...
}

Dependencies に関係していそうな大きな処理の流れは以下の通りです。

  • TestStore を initialize する
  • store.dependencies.factClient.fetch に実装を差し込む
  • .numberFactButtonTapped Action を send する (これにより factClient.fetch Dependency が呼ばれる)

要点だけ少しずつ説明してみます。

TestStore の initialize

TCA でテストを書く時に利用することになる TestStore の initialier の処理の一部を見てみます。

TestStore.swift
  public init<Reducer: ReducerProtocol>(
    initialState: @autoclosure () -> State,
    reducer: Reducer,
    prepareDependencies: (inout DependencyValues) -> Void = { _ in },
    file: StaticString = #file,
    line: UInt = #line
  )
  where
    Reducer.State == State,
    Reducer.Action == Action,
    State == ScopedState,
    Action == ScopedAction,
    Environment == Void
  {
    var dependencies = DependencyValues()
    dependencies.context = .test
    prepareDependencies(&dependencies)
    let initialState = DependencyValues.$_current.withValue(dependencies) { initialState() }
    // ...
    self.dependencies = dependencies
}

TestStore には prepraDependencies という DependencyValues の値を変更することができる closure を渡すことができます。
そして、それには TestStore の initializer 内で作成された DependencyValues が渡されるようになっています。
今回のテストの場合、prepareDependencies は無視しても大丈夫です。

重要なのは dependencies.context = .test の記述です。
context というのは live の方でも出てきた DependencyContext を指していて、TCA のライブラリ内に定義されているため、setter を利用して .test を代入することができるようになっています。

Context.swift
extension DependencyValues {
  // ...
  public var context: DependencyContext {
    get { self[DependencyContextKey.self] }
    set { self[DependencyContextKey.self] = newValue }
  }
}

live の時に getter の挙動は見ましたが、setter の挙動はまだ見ていません。
今回 dependencies.context = .test によって、この setter が呼び出されるため、そこで何が起こるかも見てみます。
setter に記述されている self[DependencyContextKey.self] = newValueDependencyValues の subscript を利用しています。
そのため、subscript のコードを以下に示します。(呼ばれることになる setter 部分のみ)

DependencyValues.swift
public struct DependencyValues: Sendable {
  // ...
  private var storage: [ObjectIdentifier: AnySendable] = [:]

  // ...

  public subscript<Key: TestDependencyKey>(
    key: Key.Type,
    file: StaticString = #file,
    function: StaticString = #function,
    line: UInt = #line
  ) -> Key.Value where Key.Value: Sendable {
    get {
      // ...
    }
    set {
      self.storage(ObjectIdentifier(key)] = AnySendable(newValue)
    }
  }
}

実装を見てみると subscript の setter で、DependencyValues が保持している storage に受け取った DependencyKey を key として newValueAnySendable に包んでセットしていることがわかります。
live の時は storage が利用されていませんでしたが、dependencies.context = .test のように明示的に dependencies にセットを行うと、この storage に Dependency が格納されるという仕組みになっています。

storage に値が入っている時に、例えば @Dependency(\.context) var context 的な形で contextwrappedValue にアクセスすると、DependencyValuessubscript の getter の処理に辿り着き、

DependencyValues.swift
get {
  guard let dependency = self.storage[ObjectIdentifier(key)]?.base as? Key.Value
}

live で紹介した上記の部分で storage に入っている値を利用するようにできる、という仕組みになっています。
このため、テストの時に Dependencies を override すると良い感じにその値が使われる挙動になっています。

store.dependencies.factClient.fetch に実装を差し込む

先ほどの dependencies.context = .test の挙動が理解できていれば、こちらの store.dependencies.factClient.fetch に実装を差し込む挙動についても同様のものなので、理解できると思います。
原理は同じですが、テスト時には store.dependencies.factClient.fetch = { "\($0) is a good number Brent" } のように Dependencies を override すると、Dependency を利用した時にその値が読まれるようになります。

.numberFactButtonTapped Action を send する

最後に Action が send されて実際に factClient Dependency がテストで呼ばれるタイミングです。
今までの流れと同様に、このタイミングでは DependencyValuessubscript の getter が呼び出されるため、その処理を見てみます。

DependencyValues.swift
  public subscript<Key: TestDependencyKey>(
    key: Key.Type,
    file: StaticString = #file,
    function: StaticString = #function,
    line: UInt = #line
  ) -> Key.Value where Key.Value: Sendable {
    get {
      guard let dependency = self.storage[ObjectIdentifier(key)]?.base as? Key.Value
      else {
        let context =
          self.storage[ObjectIdentifier(DependencyContextKey.self)]?.base as? DependencyContext
          ?? defaultContext

        switch context {
        case .live, .preview:
	  // ...
        case .test:
          var currentDependency = Self.currentDependency
          currentDependency.name = function
          return Self.$currentDependency.withValue(currentDependency) {
            self.cachedValues.value(
              for: Key.self,
              context: context,
              file: file,
              function: function,
              line: line
            )
          }
        }
      }
      return dependency
    }
    set {
      // ...
    }
  }

今回は事前に store.dependencies.factClient.fetch = ... という形で Dependencies を override しているため、storage に格納されている値が優先されて利用されることになり、その値が即座に return されるようになっています。

しかし、例えば Dependencies を override していない状態 (ここでは testValueunimplemented を実装していると仮定します) で、Dependency が呼ばれると storage に値が入っていないため、else の処理に突入します。
else の処理に入ると、context.test なので、以下のようなロジックが実行されます。

DependencyValues.swift
case .test:
  var currentDependency = Self.currentDependency
  currentDependency.name = function
  return Self.$currentDependency.withValue(currentDependency) {
    self.cachedValues.value(
      for: Key.self,
      context: context,
      file: file,
      function: function,
      line: line
    )
  }

ここで、ようやく @TaskLocal に触れようと思います。
Self.currentDependency つまり DependencyValues.currentDependencyDependencyValues 内で @TaskLocal として以下のように定義されています。

DependencyValues.swift
public struct DependencyValues: Sendable {
  // ...
  @TaskLocal static var currentDependency = CurrentDependency()
  // ...
}

struct CurrentDependency {
  var name: StaticString?
  var file: StaticString?
  var fileID: StaticString?
  var line: UInt?
}

TaskLocal は、Task context で利用できる便利な Property Wrapper です。詳しくは Apple の Document を参照ください。

TaskLocal は Task context で利用できると言いましたが、synchronous context / asynchronous context どちらからでも TaskLocal の値を読み取ることができます。
そして TaskLocal の withValue を使うと、Task.detached などを利用して Task を detach しない限り、その context 内では withValue で設定した値が context 内で引き継がれるようになります。
この挙動については Apple の Document に書かれているコードが分かりやすいと思います。

@TaskLocal
static var traceID: TraceID?

print("traceID: \(traceID)") // traceID: nil

$traceID.withValue(1234) { // bind the value
  print("traceID: \(traceID)") // traceID: 1234
  call() // traceID: 1234

  Task { // unstructured tasks do inherit task locals by copying
    call() // traceID: 1234
  }

  Task.detached { // detached tasks do not inherit task-local values
    call() // traceID: nil
  }
}

func call() {
  print("traceID: \(traceID)") // 1234
}

この挙動のため、「TaskLocal はあるスコープだけで値を設定しておきたい」時にも利用することができます。
DependencyValues でもそのような使い方がされていて、

DependencyValues.swift
case .test:
  var currentDependency = Self.currentDependency
  currentDependency.name = function
  return Self.$currentDependency.withValue(currentDependency) {
    self.cachedValues.value(
      for: Key.self,
      context: context,
      file: file,
      function: function,
      line: line
    )
  }

では、currentDependency に現在処理が行われている Dependency の情報を withValue で渡しつつ、その context 内で cachedValues.value が呼ばれていることがわかります。

cachedValues.value の挙動自体は live の時とほぼ同じであるため、詳細な説明は省略しますが、

DependencyValues.swift
private final class CachedValues: @unchecked Sendable {
  // ...
  func value<Key: TestDependencyKey>(
    for key: Key.Type,
    context: DependencyContext,
    file: StaticString = #file,
    function: StaticString = #function,
    line: UInt = #line
  ) -> Key.Value {
    self.lock.lock()
    defer { self.lock.unlock() }

    let cacheKey = CacheKey(id: ObjectIdentifier(key), context: context)
    guard let value = self.cached[cacheKey]?.base as? Key.Value
    else {
      // ...
      guard let value = value
      else {
        if !DependencyValues.isSetting {
          var dependencyDescription = ""
          if let fileID = DependencyValues.currentDependency.fileID,
            let line = DependencyValues.currentDependency.line
          {
            dependencyDescription.append(
              """
                Location:
                  \(fileID):\(line)
              """
            )
          }
          dependencyDescription.append(
            Key.self == Key.Value.self
              ? """
                Dependency:
                  \(typeName(Key.Value.self))
              """
              : """
                Key:
                  \(typeName(Key.self))
                Value:
                  \(typeName(Key.Value.self))
              """
          )

          runtimeWarn(
            """
            "@Dependency(\\.\(function))" has no live implementation, but was accessed from a \
            live context.
            \(dependencyDescription)
            Every dependency registered with the library must conform to "DependencyKey", and \
            that conformance must be visible to the running application.
            To fix, make sure that "\(typeName(Key.self))" conforms to "DependencyKey" by \
            providing a live implementation of your dependency, and make sure that the \
            conformance is linked with this current application.
            """,
            file: DependencyValues.currentDependency.file ?? file,
            line: DependencyValues.currentDependency.line ?? line
          )
        }
        return Key.testValue
      }

      self.cached[cacheKey] = AnySendable(value)
      return value
    }

    return value
  }
}

その内部では TaskLocal な DependencyValues.currentDependency を利用して runtime warning を出していることがわかります。
cachedValues.value は TaskLocal の withValue の context 内で呼ばれているため、withValue で設定されている値をその context 内で読み取ることができています。

この記事では説明しきることができませんが、Dependencies の中では他にも TaskLocal の仕組みが利用されており、そのおかげで柔軟な Dependency の管理が行えるようになっています。

おわりに

この記事では、live / test でどのように動作しているのか追ってみることで魔法のような Dependencies の仕組みについて説明しました。

この Dependencies の仕組みは、本記事執筆時点では TCA のライブラリに含まれているため、Dependencies だけを利用するということは難しい状態です。
しかし、Discussion で、この Dependencies を単一のライブラリにすることを検討している旨も記載されており、いずれ TCA を利用していなくても Dependencies の恩恵を受けられる時が来るかもしれません。 (TCA だからこそこのシンプルな DI の仕組みにできているという部分があるので、時間がかかるかもしれませんが)

また、想像以上に記事が長くなってしまって、本当なら

  • TCA における利用シーンから見えなかった DependencyValues の他の利用方法
  • Combine で Dependencies を利用する際の注意点

なども書きたかったのですが、一旦この記事は長すぎるのでここで終えることにします🙇
余力があれば、上記についても別記事などで書いていこうかなと思っています。

Discussion

ログインするとコメントできます