# 可單元測試的程式碼 - 依賴注入 ## 前言 單元測試在我們軟體工程實務的課程已經有稍微提過了,只不過我認為課程上主要是在講 Junit 怎麼用的部分,比較專注在單元測試怎麼寫這一塊。 而我認為實際上撰寫程式碼時,最困難的不是如何寫測試,而是怎麼寫出能夠被單元測試的程式碼。我想藉由這篇文章來分享我的見解。這邊先說個前提,我們討論的範圍著重在物件導向程式上,其他像是函數式程式設計,這一塊我不清楚,所以我不發表看法。 ## 單元測試的簡介 雖然上課提過了,不過我還是先講一下單元測試是什麼。單元測試主要是針對程式碼的最小元素來進行測試,在物件導向中最小的單元是 Method,不過一般來說我講單元測試還是會偏重在 Class 類別上,因為實際專案大起來,類別的數量也是很多的,尤其如果又使用 Uncle Bob 提出的尖叫式架構(Screaming Architecture),Method 感覺有點粒度太細了,這部分是我的看法,不見得是對的。 一般在單元測試設計時(其實不只在單元測試,任何測試都這樣),主要會有三個部分,分別為 Arrange、Act、Assert,這叫做 3A 模式或叫做 3A 原則,Arrange 主要就是 setup 你的測試資料,Act 則是執行測試,Assert 則是判斷執行的結果是否符合預期。 ```kotlin= import org.junit.Assert.assertEquals import org.junit.Test class MathUtils { fun addNumbers(a: Int, b: Int): Int { return a + b } } class MathUtilsTest { @Test fun testAddNumbers() { // Arrange val mathUtils = MathUtils() val a = 2 val b = 3 val expected = 5 // Act val result = mathUtils.addNumbers(a, b) // Assert assertEquals(expected, result) } } ``` 而另一個常見的說法是 Given、When、Then,這和 Arrange、Act 和 Assert 可以相互對應。只不過 3A 我認為是比較偏向測試碼的安排格式,而 GWT 比較適合用來作為一種測試的設計方式,因為你在你的 test 名稱上直接寫 ```kotlin fun `given two even numbers when added then return even number`() { // 測試邏輯 } ``` 可以很容易的讓人看得出來這個測試想要測什麼,同時又解決了程式設計的千古難題 - 命名。 Given 了一個情境 -> 做了某件事情 -> 預期得到什麼結果 相比於 3A 來說,GWT的語意會是比較清晰的。 ## 實際上單元測試會遇到的挑戰 這是我從[一篇文章](https://ithelp.ithome.com.tw/articles/10259638)中找到的,作者看了一本書叫做 *The Art of Unit Testing* 後對裡面單元測試在書本翻譯的定義, > 一個單元測試是一段自動化的程式碼,這段程式會呼叫被測試的工作單元,之後對這個單元的單一最終結果的某些假設或期望進行驗證。單元測試幾乎都是使用單元測試框架進行撰寫的。撰寫單元測試很容易,執行起來快速。單元測試可靠、易讀、並且很容易維護。只要產品程式碼不發生變化,單元測試的執行結果是穩定一致的。 重點是在最後面這一段,「只要產品程式碼不發生變化,單元測試的執行結果是穩定的」。我們再來看同一本書作者對整合測試的定義, > 整合測試是對一個工作單元進行測試,而這個測試對被測試的單元並沒有完全的控制,而是使用該單元的一個或多個真實依賴的相依物件,例如時間、網路、資料庫、執行緒、或亂數產生器等等。 所以從上面這兩個定義來看,單元測試和整合測試的主要差別在於我們對受測程式碼之外的變因控制程度。那從這樣的定義來看,會出現什麼問題呢? 假設我有一個網頁後端程式,客戶要求要有一個登入功能,且登入失敗超過三次時就鎖起來,若登入成功就重置登入嘗試次數。這時你創建了一個 service 負責用戶的登入驗證,你很理所當然的寫下了一段程式碼(以kotlin來說) ```kotlin= class UserRepository { private val db: DatabaseHelper() // 這段我亂掰的,大概有個意思就好 fun getUserByAccount(account: String): User? { // implementation } fun delete(id: String): Boolean { // implementation } fun save(user: User): Boolean { // implementation } } ``` ```kotlin= class LoginService { private val userRepository = UserRepository() fun login(account: String, password: String): Result { val user = userRepository.getUserByAccount(String) if (user == null) { return Result(false, null, "user not exists") } if (user.hasTried >= 3) { return Result(false, null, "the account has been locked") } val isValid = user.checkPasswordValid(password) if (isValid) { user.hasTried = 0 userRepository.save(user) return Result(true, user, "") } user.hasTried += 1 userRepository.save(user) return Result(false, null, "invalid password") } class Result( val isSuccess: bool, val user: User?, val failedMessage: String ) } ``` 乍一看貌似是一段很乾淨,很漂亮的程式碼,既能檢查使用者是否存在,又能判斷是否為正確密碼,超過三次鎖起來也有,客戶的要求應有盡有。 ![](https://hackmd.io/_uploads/SyP32Bmrh.jpg) 話是這麼說沒錯,但是身為一個軟體工程師,程式碼不是跑得起來就可以,而是要能夠經過一些驗證測試,確保程式碼的品質,而實際上當你要帶入上面單元測試的定義後去測 LoginService 你會遇到一些問題 1. 你對你的測試資料掌控度可以說幾乎是 0,因為你完全的把 userRepository 寫死在類別裡面。 2. 你勢必需要和真實的資料庫去做測試,因為 UserRepository 是已經寫好的一個類別,直接與 production 的資料庫做聯繫。 3. 你的 LoginService 和 UserRepository 是強烈耦合的,一旦 UserRepository 修改了,會直接影響到 LoginService,這和「只要產品程式碼不發生變化,單元測試的執行結果是穩定的」是相抵觸的(這邊的產品程式碼為受測程式碼,也就是LoginService)。 4. 無法得知的副作用,你不知道傳到 userRepository.save() 的修改過的 User 是不是正確的。 既然問題來了就要解決,我們從最基本的觀念開始講。 ## 緊耦合、鬆耦合 耦合 (Coupling) 是指系統模組之間有相關聯,這些關聯的模組需要彼此才能夠運作,這在軟體設計中是一個重要的議題,小從類別與類別的耦合,大至微服務架構中微服務之間的耦合。 一般來說,耦合度越高代表整體的維護成本越高,因為系統之間高度依賴彼此,這就導致修改不容易,牽一髮而動全身。而與耦合相對的概念叫做內聚 (Cohesion),若系統之間的模組有高內聚力,則代表個模組的獨立性較高,在修改特定模組時比較不容易直接影響到另一個模組。 高耦合不好,沒有軟體可以做到完全的零耦合,過高的內聚也不好,程式碼很容易變成不斷的複製貼上,因此拿捏好耦合的程度就是軟體設計的一門學問,各種的設計模式和架構就是在為這一系列的問題提出解決方案。 接下來用剛剛的例子來看看我們的耦合度。 ### LoginService 和 UserRepository 的耦合 從我們剛剛的程式碼可以看到 LoginService 是直接在內部實例化了 UserRepository,這使得如果修改了 UserRepository 內部的實作方式,例如這時候我想幫他的建構子多加了幾個參數,那我在所有用到 UserRepository 的地方都要再傳這些參數進去。 這種耦合的程度就稱為緊耦合。 由於我們不可能讓程式完全沒有耦合,我們只能讓其變成鬆耦合,具體可以怎麼解決呢?首先我們觀察到,LoginService 只在乎拿到 User 這件事情,根本不在意到底要傳什麼參數給 UserRepository 的建構子。也就是說 UserRepository 的實例化根本不是他該做的工作。所以我們可以把這件事情交給別人做,要交給誰做就不是 LoginService 要考慮的事情了,他只要在建構時接收外部傳進來的 UserRepository 就好了。 ```kotlin= class LoginService(private val userRepository: UserRepository) { // 剛剛在這邊的實例化的行為就交給外面的人做了,LoginService 的責任減少了 fun login(account: String, password: String): Result { val user = userRepository.getUserByAccount(String) if (user == null) { return Result(false, null, "user not exists") } if (user.hasTried >= 3) { return Result(false, null, "the account has been locked") } val isValid = user.checkPasswordValid(password) if (isValid) { user.hasTried = 0 userRepository.save(user) return Result(true, user, "") } user.hasTried += 1 userRepository.save(user) return Result(false, null, "invalid password") } class Result( val isSuccess: bool, val user: User?, val failedMessage: String ) } ``` 這個舉動就是本文的重點 — **依賴注入** ## 依賴注入 依賴 (Dependency) 這個名詞很好理解,若 A 需要 B,那 B 就是 A 的依賴。上面的例子即為 UserRepository 是 LoginService 的一個依賴。 而依賴注入就是把 B 模組直接提供給 A 模組,而不是 A 模組自己實例化 B 模組。通常依賴注入常見的手法大概就三種,第三種我後面再講 1. 建構子注入: 透過建構子的方式傳入依賴,這個是依賴注入場合中最常見的方式。 ```kotlin= class Computer( private val cpu: CPU, private val ram: Ram, private val motherboard: MotherBoard, private val power: Power ) { // inside logic } ``` 2. 方法注入: 透過 method 的方式注入,這在某些設計模式可以看到,例如策略模式。 ```kotlin= // Base Strategy interface Sorter { sort(list: List<int>): List<int> } // Concrete Strategy class BubbleSorter: Sorter { override fun sort(list: List<int>): List<int> { // implementation } } // Strategy user class SortService { private lateinit var sorter: Sorter // 在此透過 setter 注入 fun setSorter(sorter: Sorter) { this.sorter = sorter } fun execute(list: List<int>) List<int> { return sorter.sort(list) } } class Main { fun main() { val sortService = SortService() sortService.setSoter(BubbleSorter()) val result = sortService.execute(List<int>()) } } ``` 依賴注入其實差不多就這樣子而已,他並不是什麼太高大上的東西,那這東西究竟有什麼好處呢?不過就是透過建構子傳遞參數進來而已嗎? 坦白來說我剛認識這個概念的時候也不是很清楚這概念是可以幹嘛,為什麼要幫一個平凡的動作加上一個看起來很厲害的名字呢?直到我學了**單元測試**後才了解他的用處,因為你如果不使用依賴注入,根本就無法做單元測試呀! ## 繼續改進 OK,我們提到了依賴注入了,也使用了依賴注入,但是實際上我們的問題依舊是沒有解決,程式碼依舊是緊耦合。 可以發現我們的 LoginService 對於 UserRepository 他只在乎能不能拿到 User,所以今天你 UserRepository 要從 SQL 拿,或是 NoSQL 資料庫拿,或從文字檔拿他都不管,因此我們可以再把這層抽象出一個介面 ```kotlin= interface UserRepository { fun getUserByAccount(account: String): User? fun delete(id: String): Boolean fun save(user: User): Boolean } ``` 而我們原本的 LoginService 就變成完全是依賴抽象的 ```kotlin= // 此時 LoginService 是連 UserRepository 的實例是什麼都不 care 的,只要給他需要的函數就可以 class LoginService(private val userRepository: UserRepository) { fun login(account: String, password: String): Result { val user = userRepository.getUserByAccount(account) if (user == null) { return Result(false, null, "user not exists") } if (user.hasTried >= 3u) { return Result(false, null, "the account has been locked") } val isValid = user.checkPasswordValid(password) if (isValid) { user.hasTried = 0u userRepository.save(user) return Result(true, user, "") } user.hasTried += 1u userRepository.save(user) return Result(false, null, "invalid password") } class Result( val isSuccess: Boolean, val user: User?, val failedMessage: String ) } ``` 而原本的 UserRepository 的則變成 UserRepositoryImpl,並且實作 UserRepository 規範的介面。 ```kotlin= class UserRepositoryImpl: UserRepository { private val db = DatabaseHelper() override fun getUserByAccount(account: String): User? { // implementation } override fun delete(id: String): Boolean { // implementation } override fun save(user: User): Boolean { // implementation } } ``` 我們再來回顧一下前面遇到的四個測試問題,來看看我們已經解決了哪些問題 - [x] 1. ~~你對你的測試資料掌控度可以說幾乎是 0,因為你完全的把 userRepository 寫死在類別裡面。~~ 透過依賴注入我們解決了這一點 - [ ] 2. 你勢必需要和真實的資料庫去做測試,因為 UserRepository 是已經寫好的一個類別,直接與 production 的資料庫做聯繫。 - [x] 3. ~~你的 LoginService 和 UserRepository 是強烈耦合的,一旦 UserRepository 修改了,會直接影響到 LoginService,這和「只要產品程式碼不發生變化,單元測試的執行結果是穩定的」是相抵觸的(這邊的產品程式碼為受測程式碼,也就是LoginService)。~~ 透過抽象化界面,我們達到了這一點 - [ ] 4. 無法得知的副作用,你不知道傳到 `userRepository.save(user)` 的修改過的 User 是不是正確的。 進度已經到一半了!繼續努力! ## Stub 虛設常式 / Mock 模擬物件 在上課時我們已經有提到 Stub 的概念,課堂中老師提供的中文翻譯為「殘根」,而我在搜尋資料時看到有人是稱其為「虛設常式」,前者比較像是直接翻譯,後者比較能讓我理解這個命名的意義。 和 Stub 概念很像的另一個東西叫做 Mock 模擬物件,其實老師在講 Stub 的時候我一直在想這和 Mock 是差在哪裡?兩者看起來都是為了隔離受測程式的依賴,避免測試時出現預期外的事情。在我搜尋了資料後([參考](https://hackmd.io/@AlienHackMd/HycK5pEpo#%E6%A8%A1%E6%93%AC%E7%89%A9%E4%BB%B6-Mock)),給出了一個簡單的總結 > 「比較笨的就是 Stub,稍微有點邏輯的是 Mock」 一般來說 Stub 的回傳值就是一個常數,一般情況是不能隨意修改 Stub 回傳的結果的,為了要確保狀況的不變性,才能穩定測試的正確度。 而 Mock 就如同他的名字而言,是為了拿來模擬其他物件會做的事情,並用來檢測與受測程式的互動性,Mock 內部的東西則有自己的一套邏輯。 所以從這兩個的功能來看,單元測試會是比較喜歡 Stub 的,因為是常數,穩定性較高。但是現實情況是總有一些地方是需要 Mock 的。就以我們的程式來說吧,你要怎麼知道 `userRepository.save(user)` 傳進去的 user 資料是正確的?你要怎麼避免測試時用到 Production 的資料庫呢?這種會影響到受測程式類別之外的情況我們稱之為 side effect。而通常要測試 side effect 的作法就是用 Mock。 我們繼續來看看剛剛的程式碼可以怎麼拿來做測試。我們觀察到 save 這個動作是有 side effect 的,因此我們建立一個 mock 物件用來檢查 side effect ```kotlin= class LoginServiceUnitTest { @Test fun `given user that tried 2 times when login failed again then save 3 times failure`() { // Arrange val mockUserRepository = object: UserRepository { private var user = User( account = "123" password = "123" hasTried = 2 ) override fun getUserByAccount(account: String): User { if (account != "123") { return null } return user } override fun delete(id: String): Boolean { return false } override fun save(user: User): Boolean { this.user = user } fun getUser(): User { return user } } val account = "123" val password = "789" val loginService = LoginService(mockUserRepository) // Act val _ = loginService.login(account, password) // Assert val expectedUser = User( account = "123", password = "123", hasTried = 3 ) val actualUser = mockUserRepository.getUser() assertEquals(expectedUser, actualUser) } } ``` 不知道各位有沒有發現一件事情,我們從頭到尾都不需要資料庫呀,這就是 Mock 的用途,透過模擬物件我們就可以隔離受測程式之外的環境了。 再來看看剛剛的問題 - [x] 1. ~~你對你的測試資料掌控度可以說幾乎是 0,因為你完全的把 userRepository 寫死在類別裡面。~~ 透過依賴注入我們解決了這一點 - [x] 2. ~~你勢必需要和真實的資料庫去做測試,因為 UserRepository 是已經寫好的一個類別,直接與 production 的資料庫做聯繫。~~ 利用 Mock 我們完全不需要用到資料庫 - [x] 3. ~~你的 LoginService 和 UserRepository 是強烈耦合的,一旦 UserRepository 修改了,會直接影響到 LoginService,這和「只要產品程式碼不發生變化,單元測試的執行結果是穩定的」是相抵觸的(這邊的產品程式碼為受測程式碼,也就是LoginService)。~~ 透過抽象化界面,我們達到了這一點 - [x] 4. ~~無法得知的副作用,你不知道傳到 userRepository.save() 的修改過的 User 是不是正確的。~~ 透過 Mock 我們可以檢查 LoginService 造成的副作用 Done! 我們解決了四個問題後,單元測試也自然就寫出來了!程式碼之間的耦合度也降低了許多。我認為這也是為什麼 TDD 這麼備受推崇的原因,透過測試去完成程式碼,某種程度是 force 你做到降低耦合的一個做法。 而這一切都是有了依賴注入這個步驟我們才有辦法做到這樣的成果,有了依賴注入後再加上一點抽象的概念,一切都豁然開朗了。 ## 依賴注入框架 ### 為什麼要依賴注入框架? 剛才我們一直是以 LoginService 的角度來看程式碼,注入後變得很乾淨沒錯,但如果仔細想想,你依舊是要在別的地方做實例化。例如一個網頁後端可能在 entry point 時就得寫這一堆東西 ```kotlin= fun main() { val db = DatabaseHelper() val userRepository = UserRepositoryImpl(db) val loginService = LoginService(userRepository) val productRepository = ProductRepository(db) val checkoutService = CheckoutService(userRepository, productRepository) val loginController = LoginController(loginService) // 更多的實例化... } ``` 如果規模再大一點、建構子再長一點、依賴關係再更複雜一點,可想而知這一坨東西會很不好管理,我可以給各位看我在上學期資料庫設計的期末專題裡的簡易論壇的 entry point 長什麼樣子,這只是非常簡易的後端而已,如果變得更大實在是看得很煩躁。 ```go= package main import ( "database/sql" _ "github.com/mattn/go-sqlite3" "log" "github.com/gofiber/fiber/v2" "MyForum/implements/controller" "MyForum/implements/service" "MyForum/implements/repository" "github.com/gofiber/fiber/v2/middleware/cors" ) func main() { db, err := sql.Open("sqlite3", "./database/db.db") if err != nil { log.Fatal("Invalid DB config:", err) } if err := db.Ping(); err != nil { log.Fatal("Could not connect to database:", err) } // sqlite default foreign key is OFF so we need to turn it on // otherwise the comments under the deleted post will not be deleted if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { log.Fatal("Could not set foreign key constraint to True:", err) } app := fiber.New() app.Use(cors.New(cors.Config{ AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Access-Control-Allow-Origin,Access-Control-Allow-Headers,Authorization", AllowOrigins: "*", AllowCredentials: true, AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS", })) // create repositories userRepository := repository.NewUserRepositoryImpl(db) communityRepository := repository.NewCommunityRepositoryImpl(db) postRepository := repository.NewPostRepositoryImpl(db) commentRepository := repository.NewCommentRepositoryImpl(db) // create services userService := service.NewUserServiceImpl(userRepository) communityService := service.NewCommunityServiceImpl(communityRepository) postService := service.NewPostServiceImpl(postRepository, postRepository, communityRepository) commentService := service.NewCommentServiceImpl(commentRepository, commentRepository) // create controllers userController := controller.NewUserControllerImpl(userService) communityController := controller.NewCommunityControllerImpl(communityService, postService) postController := controller.NewPostControllerImpl(postService, postService, communityService, commentService, userService) commentController := controller.NewCommentControllerImpl(commentService, commentService, userService) // register routes apiGroup := app.Group("/api") userGroup := apiGroup.Group("/user") communityGroup := apiGroup.Group("/community") postGroup := apiGroup.Group("/post") commentGroup := apiGroup.Group("/comment") // user routes userGroup.Post("/login", userController.Login) userGroup.Post("/register", userController.Register) userGroup.Delete("/delete", userController.Delete) userGroup.Get("/private", userController.GetUserPrivateInfo) // community routes communityGroup.Get("/", communityController.GetCommunities) communityGroup.Get("/:community_id/:page", communityController.GetPostsByCommunityId) // post routes postGroup.Post("/create", postController.CreatePost) postGroup.Delete("/delete/:post_id", postController.DeletePost) postGroup.Get("/:post_id/:page", postController.GetPost) postGroup.Put("/update/:post_id", postController.UpdatePost) postGroup.Post("/vote/:post_id/:vote_param", postController.Vote) postGroup.Get(":post_id/comments", postController.GetCommentsByPostId) // comment routes commentGroup.Post("/create/", commentController.CreateComment) commentGroup.Delete("/delete/:comment_id", commentController.DeleteComment) commentGroup.Put("/update/:comment_id", commentController.UpdateComment) commentGroup.Post("/vote/:comment_id/:vote_param", commentController.Vote) log.Fatal(app.Listen(":3000")) } ``` 為了解決這種問題,其實已經有一些框架幫我們簡化和美化這些操作了。如果是寫 Java 的人一定都聽說過 Spring Boot,非常強大的框架,一般寫 web 後端都會用到的一個工具,裡面其中一個功能就有依賴注入。但是我個人沒什麼在寫 Java,所以我對 Spring Boot 也不了解。 而在這學期的另一堂課行動裝置程式設計,雖然老師沒有提到,但因為我想要練習看看依賴注入框架,所以就找到了 Dagger2 這個框架,這個框架主要的應用領域在於 App 領域,至於為什麼在 App 領域比較好我也不知道,我猜可能是 Spring Boot 都把 web 的光芒都搶走了吧。 ### Dagger2 簡介 ![](https://hackmd.io/_uploads/ByA0r7yL3.png) 有玩過一些遊戲的都知道 Dagger 是匕首的意思,我覺得叫 Dagger 的原因是因為匕首刺入的樣子就如同依賴注入一樣,把依賴刺入另一段程式碼。 Dagger2 起源於 Dagger,而 Dagger 是一個開源框架,因為一些效能問題,被 Google 的工程師發現後做了一些改動,弄出了 Dagger2,詳細歷史我就不贅述。 由於 Dagger2 這個框架內容頗多的,去看 Youtube 是有一整個 tutorial series 的,我就重點講解一些東西,能讓他跑起來,並看看他的效果即可。 ### 環境 Android Studio 創建專案後在 Gradle 設定中添加依賴(Kotlin語言環境) ``` plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' // 新增這個 id 'kotlin-kapt' } dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' /*以下為 Dagger2 依賴*/ implementation 'com.google.dagger:dagger:2.44' kapt 'com.google.dagger:dagger-compiler:2.44' /********************/ } ``` Build 後確認沒有問題就可以了 ### 程式碼背景 依舊以前面的範例為例子,簡單的三層式架構,為了簡化程式碼,我把資料庫的部分省略了。在這裡 Controller 為 MainActivity。 ![](https://hackmd.io/_uploads/rJd0GPgL2.png) ```kotlin= // User.kt class User (val id: String, val account: String, private val password: String, var hasTried: UInt) { fun checkPasswordValid(password: String): Boolean { return this.password == password } } ``` ```kotlin= // UserRepository.kt class UserRepository { private val users = mutableListOf<User>() init { users.add(User("1", "John", "123", 0u)) users.add(User("2", "Marry", "456", 0u)) } fun getUserByAccount(account: String): User? { return users.find { it.account == account } } fun delete(id: String) { users.find { it.id == id }?.let { users.remove(it) } } fun save(user: User) { if (users.find { it.id == user.id } == null) { users.add(user) return } // remove old user users.find { it.id == user.id }?.let { users.remove(it) } // add new user users.add(user) } } ``` ```kotlin= // LoginService.kt class LoginService(private val userRepository: UserRepository) { fun login(account: String, password: String): Result { val user = userRepository.getUserByAccount(account) if (user == null) { return Result(false, null, "user not exists") } if (user.hasTried >= 3u) { return Result(false, null, "the account has been locked") } val isValid = user.checkPasswordValid(password) if (isValid) { user.hasTried = 0u userRepository.save(user) return Result(true, user, "") } user.hasTried += 1u userRepository.save(user) return Result(false, null, "invalid password") } class Result( val isSuccess: Boolean, val user: User?, val failedMessage: String ) } ``` ### Dagger2 開始 > 創建一個檔案 ServiceModule.kt, Module 負責的事情是提供依賴,因此你可以擁有非常多的 Module 管理各式不同的依賴,記得在 Module 類別前加上 @Module。 而在內部,我們可以 provide 依賴,如果你這個 Module 想提供什麼那麼一般的命名就是 `fun provideXXXX()`,不過重點不是在命名,重點在於 return type,Dagger2 主要還是依據 return type 來看你這個 Module 可以提供什麼依賴。這個例子裏面 Module 提供了 LoginService 這個類別。 ```kotlin= // ServiceModule.kt @Module class ServiceModule { @Provides @Singleton fun provideLoginService(): LoginService { return LoginService(UserRepository()) } } ``` 但是如果說未來新增了很多 Service,每一個 Service 都要 UserRepository,會變成這樣 ```kotlin= // ServiceModule.kt @Module class ServiceModule { @Provides @Singleton fun provideLoginService(): LoginService { return LoginService(UserRepository()) } @Provides @Singleton fun provideRegisterService(): RegisterService { return RegisterService(UserRepository()) } @Provides @Singleton fun provideDeleteAccountService(): DeleteAccountService { return DeleteAccountService(UserRepository()) } // ... more services } ``` 每一個 Service 都新建一個 UserRepository,顯然不是很好,因此我們可以這麼改寫 ```kotlin= // ServiceModule.kt @Module class ServiceModule { @Provides @Singleton fun provideUserRepository(): UserRepository { return UserRepository() } @Provides @Singleton fun provideLoginService(userRepository: UserRepository): LoginService { return LoginService(userRepository) } @Provides @Singleton fun provideRegisterService(userRepository: UserRepository): RegisterService { return RegisterService(userRepository) } @Provides @Singleton fun provideDeleteAccountService(userRepository: UserRepository): DeleteAccountService { return DeleteAccountService(userRepository) } // ... more services } ``` Dagger2 會自動判斷這個 Module 內有沒有提供 UserRepository 的 functions,有的話會自動注入到其他需要的類別。`provideLoginService` `provideRegisterService` `provideDeleteAccountService`,都需要 UserRepository,因此 Dagger2 判斷過後會自動把 UserRepository 傳給這些 function。 > 創建 MainActivityComponent.kt Module 設定好之後,創建 Component, Component 負責的事情就是規劃要把哪些 Module 裡的依賴注入給目標類別。我們的目標是把 Service 都注入給 MainActivity,因此我們的 Component 需要的 Module 就是 ServiceModule。 ```kotlin= @Singleton @Component(modules = [ServiceModule::class]) interface MainActivityComponent { fun inject(activity: MainActivity) } ``` > Build 一次,一定要 Build 一次,因為 Dagger2 會依據你給的 Module 和 Component 來自動生成 IOC 容器。 ![](https://hackmd.io/_uploads/BJ4wHikI3.png) > 可以看到編譯後自動生成了靜態檔案,若你的 Component 為 MainActivityComponent,那麼我們的目標則是生成 DaggerMainActivityComponent,若有看到該檔案則生成正確。 ![](https://hackmd.io/_uploads/Hyvtask83.png) > 來到 MainActivity 後修改程式碼,使用@Inject將依賴注入進去,並且在建立物件時使用 DaggerMainActivityComponent 來施行注入。 ```kotlin= class MainActivity : AppCompatActivity() { @Inject lateinit var loginService: LoginService override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // 使用 Dagger 注入依賴 DaggerMainActivityComponent.create().inject(this) findViewById<Button>(R.id.login_button).setOnClickListener { onLoginButtonClick() } } fun onLoginButtonClick() { val account = findViewById<TextInputEditText>(R.id.account_input).text.toString() val password = findViewById<TextInputEditText>(R.id.password_input).text.toString() val result = loginService.login(account, password) val messageTextView = findViewById<TextView>(R.id.message) if (result.isSuccess) { messageTextView.text = "Hi, ${result.user?.account}" } else { messageTextView.text = result.failedMessage } } } ``` ### 參考執行結果 ![](https://hackmd.io/_uploads/SJaUvdxL2.png)