# Dependency Injection ## 🎬 Point・Free * [video](https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy) * [source code](https://github.com/pointfreeco/episode-code-samples/blob/main/0016-dependency-injection/Environment.playground/Contents.swift) ## GitHub <img width="300" src="https://i.imgur.com/kECfNXs.png" style="display: block;margin-left: auto;margin-right: auto;width: 50%;" /> 1. 串接 GitHub API 2. TableView 呈現結果 3. 右側顯示發佈時間與現在的時間差 [🔗 Demo link](https://github.com/bing-Guo/DependencyInjectionDemo) ## DI with Protocol ```swift= protocol GitHubProtocol { func fetchRepos(onComplete completionHandler: (@escaping (Result<[GitHub.Repo], Error>) -> Void)) } // Live GitHub struct GitHub: GitHubProtocol { struct Repo: Decodable { var archived: Bool var description: String? var htmlUrl: URL var name: String var pushedAt: Date? } func fetchRepos(onComplete completionHandler: (@escaping (Result<[GitHub.Repo], Error>) -> Void)) { dataTask("orgs/pointfreeco/repos", completionHandler: completionHandler) } } ``` ```swift= // ViewModel class ReposViewModel { // Inject dependency let date: () -> Date // Date.init let gitHub: GitHubProtocol init(date: @escaping () -> Date = Date.init, gitHub: GitHubProtocol = GitHub()) { self.date = date self.gitHub = gitHub } } ``` ```swift= // Mock GitHub struct GitHubMock: GitHubProtocol { let result: Result<[GitHub.Repo], Error> // happy path: default return .success case init(result: Result<[GitHub.Repo], Error> = .success([ GitHub.Repo( archived: false, description: "Blob's blog", htmlUrl: URL(string: "https://www.pointfree.co")!, name: "Bloblog", pushedAt: Date(timeIntervalSinceReferenceDate: 547152021) ) ])) { self.result = result } func fetchRepos(onComplete completionHandler: @escaping (Result<[GitHub.Repo], Error>) -> Void) { completionHandler(result) } } ``` 使用 protocol 做依賴注入會需要許多樣板,每加入新的方法就需要: 1. 新增新方法到 GitHubProtocol 2. 新增並實作方法到 live GitHub 3. 新增 result 到 mock 4. 更新 mock 的 init 並給定預設值(happy path) ## Without Protocol 文中提供另一種方法解決依賴注入,不需要 protocol ```swift= // App code struct Environment { var date: () -> Date = Date.init var gitHub = GitHub() } var Current = Environment() ``` 1. Environment: 結合所有的 dependencies 2. Current: 表示目前的環境,如在 App code 下,稱作為 live --- ```swift= struct GitHub { struct Repo: Decodable { var archived: Bool var description: String? var htmlUrl: URL var name: String var pushedAt: Date? } var fetchRepos = GitHub.fetchRepos(onComplete:) // Use static private static func fetchRepos(onComplete completionHandler: (@escaping (Result<[GitHub.Repo], Error>) -> Void)) { GitHub.dataTask("orgs/pointfreeco/repos", completionHandler: completionHandler) } } ``` 1. 移除 protocol 2. 取而代之是開放所需要對外的 properties,並將 `GitHub.fetchRepos(onComplete:)` 設為在 live 的預設執行內容 3. 把 fetchRepos() 設為 private,這是 live 的實作內容,不需要開放 4. 把 fetchRepos() 設為 static,文中沒特別說明,但如果不這樣做的話,型態會不正確,會被認定為 GitHub instance 下的 func,後續 mock 時就無法覆寫該 properties。(我理解是這樣,有誤麻煩補充 🧐 ) ```swift= // Static: (@escaping ((Result<[GitHub.Repo], Error>) -> Void)) -> () // non-Static: (GitHub) -> (@escaping ((Result<[GitHub.Repo], Error>) -> Void)) -> () var fetchRepos = GitHub.fetchRepos(onComplete:) ``` --- ```swift= // ViewModel class ReposViewModel { // Don't need to inject properties func loadRepos() { // Using Current singleton Current.gitHub.fetchRepos { [weak self] result in // do something... } } ``` 1. 不需要在 init 注入 2. 相依的部分換成 Current --- ```swift= // Test code extension Environment { static let mock = Environment( date: { Date(timeIntervalSinceReferenceDate: 547152051) }, gitHub: .mock ) } extension GitHub { static let mock = GitHub( fetchRepos: { callback in callback(.success([ GitHub.Repo( archived: false, description: "Blob's blog", htmlUrl: URL(string: "https://www.pointfree.co")!, name: "Bloblog", pushedAt: Date(timeIntervalSinceReferenceDate: 547152021) ) ])) } ) } ``` 1. 利用 extension 建立 mock,並給予預設值 (happy path) --- ```swift= class DependencyInjectionDemoTests: XCTestCase { var viewModel: ReposViewModel! override func setUpWithError() throws { super.setUp() Current = .mock viewModel = ReposViewModel() } ``` 1. 在 test code 當中切換 Current --- ```swift= func testFailureCase() throws { // given let expectation = expectation(description: "callback happend") Current.gitHub.fetchRepos = { callback in callback(.failure( NSError( domain: "co.pointfree", code: -1, userInfo: [NSLocalizedDescriptionKey: "Ooops!"] ) )) } viewModel.errorOccurredClosure = { _ in expectation.fulfill() } // when viewModel.loadRepos() // then wait(for: [expectation], timeout: 1) } ``` 1. 因為 Current 是 mutable,我們可以針對 Current 直接模擬現實中可能的狀況,比如說 failure 狀況 ## Q&A **以下擷取影片中的對話...** > 教科書上寫說 Singleton 好壞壞,這裡怎麼創造一個 mega-singleton?還用奇怪的大寫命名? Singletons 通常不太好測試,導致我們用更複雜的依賴注入方法去解決它,比如說在初始化時注入 protocol type。 這裡提供的解法是用 singleton 去解決 singleton 的問題,它有一系列的 mutable properties,是一個我們可以控制的 singleton,可以依照實際情況去替換內容。 要注意的是,目前為止所提到的 Environment 在 production code 都不應該去修改它,這種把所有 dependencies 放在一起的做法是為了方便測試,並且工作量更小。 大寫的部分,影片中沒特別解釋,個人認為是強調它是 global & mutable,比較像是提醒使用者。 ## Conclusion 當初想導入這個寫法,是實際遇到一些狀況 1. 想寫 unit test,但有 dependency (API object) 3. API object 是 objective-C 撰寫且歷史久遠,用上述 protocol 解決的話會遇到許多 swift <-> objective-C 型態不支援的問題 4. 撰寫 protocol 會動到 legacy code,洞可能越挖越大 使用本篇的方法體驗不錯,不用動到原本的 legacy code,也不用建立 protocol,核心想法應該是把 function 當作變數丟來丟去(這似乎在 FP 是個很常見的做法!?),需要的時候就把 function 實作內容更換掉。 ## Feedback 1. 測試可能會有平行運行問題,所以覆蓋寫可能要在 setUpWithError, finish 處理 2. 也許可以用 compiler directives 去把 var 變成 let