利用シーンから Dependencies の仕組みを紐解く
この記事は 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
という形のものもきっと見かけていると思います。
本記事では主に live
と test
環境でどのように Dependencies が動作するかを見ていくことで、仕組みを探っていこうと思います。
live
まずは live
について、実際の利用シーンから辿っていく形で見ていこうと思います。
ここでは TCA の Examples にある 02-Effects-Basics.swift から辿っていこうと思います。
このコードでは Dependency を 2 つ利用していて、continuousClock
と factClient
がそれにあたります。
今回は説明のために factClient
に絞って見てみます。まず factClient
の利用部分を以下に抜粋します。
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 のハンドリング部分では、定義したfactClient
のfetch
を利用してnumberFactResponse
を return
Dependencies を使い慣れていれば、特に目立ったものはないシンプルな処理となっています。
しかし、この @Dependency
Property Wrapper はどのように動作しているのでしょうか?また、fetch
はなぜ利用できるのでしょうか?
その辺りについて見ていくために、Dependency を利用するようになってから人によってはおまじない的に定義しているかもしれない、factClient
を Dependency として扱えるようにするためのコードを見てみます。
// 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 として利用したい
FactClient
をDependencyKey
に準拠させる -
DependencyValues
を extension してDependencyKey
に準拠させておいたFactClient
を利用した computed property を定義する
少しずつ説明していきます。
DependencyKey
とは何なのか
まず、「Dependency として利用したい FactClient
を DependencyKey
に準拠させる」部分について見ていきます。
DependencyKey
は TCA の中で定義されている protocol で、実装は DependencyKey.swift に存在しています。
ドキュメントコメントがかなり豊富ですが、実装部分だけ抜粋してみると案外シンプルな protocol となっています。
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
では previewValue
と testValue
のみを最低限実装すれば良い状態となっています。
ただ、TestDependencyKey
のみに準拠させた状態で実際のアプリケーションからその Dependency を利用しようとすると、runtime warning が出るようになっているため、実際のアプリケーションで Dependency を利用したい場合は DependencyKey
に独自の Dependency を準拠させる必要があります。 (この辺りはさらに詳しく後ほど説明します)
実際に FactClient
を 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")
)
}
DependencyKey
の protocol requirements を満たすために liveValue
と testValue
を提供していることがわかります。
ここまでで、DependencyKey
の実体がどんなものかということはある程度理解できたかなと思います。
しかし、まだ @Dependency
Property Wrapper がどのように動作するかまでは掴めていません。
そのため、次に @Dependency
Property Wrapper 自体の実装を見ていくことにします。
@Dependency
Property Wrapper の実装
実は @Dependency
Property Wrapper 自体も割とシンプルな実装になっており、Property Wrapper に慣れ親しんだ人なら (慣れ親しんでいない場合はこちらの「Property Wrappers」を参照ください)、特に奇抜だと感じる実装は存在しません。
@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
って何なんだという話はもう少しだけ目を瞑ってください)
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
、つまり実際にアプリで利用される値を定義していました。
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
を定義しているだけで、なぜ実コード上からは
@Dependency(\.factClient) var factClient
// ...
factClient.fetch(count)
のような形で、当然のように liveValue
で提供した fetch
の実装を利用することができるのでしょうか?次は、これがどのように実現されているのかについて見ていくことにします。
まず factClient.fetch(count)
という形で factClient
にアクセスしていますが、Property Wrapper の性質上、factClient
という形で property にアクセスした場合、Property Wrapper の wrappedValue
にアクセスすることになります。
ここで、wrappedValue
がどんな実装になっていたかを再掲します。
@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
についても少しだけコードを見てみます。
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 を定義していました。
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 がどのように実装されているかを見ていきます。
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
(Key
は TestDependencyKey
なので、Value
は associatedtype) にアクセスしようと試みています。
storage
は DependencyValues
に定義されている Dictionary になっています。
public struct DependencyValues: Sendable {
// ...
private var storage: [ObjectIdentifier: AnySendable] = [:]
Dictionary の value となっている AnySendable
は型消去を行うための単なる wrapper struct です。
private struct AnySendable: @unchecked Sendable {
let base: Any
@inlinable
init<Base: Sendable>(_ base: Base) {
self.base = base
}
}
DependencyValues
の storage
には何も格納されていないため、この guard の else の処理に入っていきます。
else の中では、まず storage
に対して DependencyContextKey.self
を利用して DependencyContext
というものを取得しようとしています。
let context =
self.storage[ObjectIdentifier(DependencyContextKey.self)]?.base as? DependencyContext
?? defaultContext
storage
には何も入っていないため、ここでは defaultContext
というものが context
に格納されることになりますが、DependencyContext
というのは実はこの記事の冒頭の方で少しだけ出てきたりはしていたのですが、非常にシンプルな enum となっていて、DependencyContextKey
は Dependencies に付属しているいくつかの Dependency のうちの一つとして定義されているものになっています。
では subscript
の処理に戻って、context
に格納されることになる defaultContext
についても見てみます。
defaultContext
は DependencyValues.swift
の中で定義されていて、ProcessInfo
によって preview
か live
どちらかの DependencyContext
を返却するようなシンプルな実装になっています。
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 の内部に定義されていて、
public struct DependencyValues: Sendable {
// ...
private var cachedValues = CachedValues()
// ...
}
のように CachedValues
という class をインスタンス化する形で定義されています。
CachedValues
class についても 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 を利用しているコードは以下のようになっています。
@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 の処理の一部を見てみます。
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
を代入することができるようになっています。
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] = newValue
は DependencyValues
の subscript を利用しています。
そのため、subscript のコードを以下に示します。(呼ばれることになる setter 部分のみ)
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 として newValue
を AnySendable
に包んでセットしていることがわかります。
live の時は storage
が利用されていませんでしたが、dependencies.context = .test
のように明示的に dependencies
にセットを行うと、この storage
に Dependency が格納されるという仕組みになっています。
storage
に値が入っている時に、例えば @Dependency(\.context) var context
的な形で context
の wrappedValue
にアクセスすると、DependencyValues
の subscript
の getter の処理に辿り着き、
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 がテストで呼ばれるタイミングです。
今までの流れと同様に、このタイミングでは DependencyValues
の subscript
の getter が呼び出されるため、その処理を見てみます。
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 していない状態 (ここでは testValue
で unimplemented
を実装していると仮定します) で、Dependency が呼ばれると storage
に値が入っていないため、else の処理に突入します。
else の処理に入ると、context
が .test
なので、以下のようなロジックが実行されます。
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.currentDependency
は DependencyValues
内で @TaskLocal
として以下のように定義されています。
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
でもそのような使い方がされていて、
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 の時とほぼ同じであるため、詳細な説明は省略しますが、
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