iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 16
0

相依注入 - Dependency Injection ,對於 OOP 來說是一個很重要的概念,各框架、語言也都有非常多的函式庫可以用。主流的注入的方法主要有兩種:setter, constructor ,但這兩種方法都是必須要有“Object”才能夠達成,對於一個不會有“Object”的 functional programming 來說,要怎麼做到 Dependency Injection 呢?今天就來介紹 functional programming 界的 Dependency Injection - Reader Monad。

看到這邊讀者可能有疑問,之前介紹的 Algebraic Data Type 不是使用 class 做的嗎?在 Kotlin 中產生一個 class 的實例不就是是一個“Object”嗎?其實在 Functional Programming 的世界,這個 class 比較像是被當成 "Struct" 來使用,Algebraic Data Type 不會有任何的 function ,沒有任何行為,就只是一個資料。

Injection

如果要在 function 中注入實例,最直覺的想法當然是從參數注入了:

class Point(val x: Int, val y: Int)

val add: (Point, Point) -> Point = { first, second ->
    Point(first.x + second.x, first.y + second.y)
}

那如果現在注入的是一個複雜的類別呢?下面是一個 Github 的應用程式的範例。

// 1 
interface GithubRepository {
    fun login(email: String): Either<Throwable, String>
    ...
    fun getRepos(userId: String): List<Repo>
    fun getIssues(repoId: String): List<Issue>
    ...
}

// 2 
interface GithubService {
    fun searchIssue(userId: String, keyword: String, githubRepository: GithubRepository): List<Issue>
    fun login(email: String, githubRepository: GithubRepository): Maybe<User>
    fun addComment(issueId: String, comment: String, githubRepository: GithubRepository): Boolean
}

//3 
class User(val userId: String)
class Repo(val repoId: String, val title: String)
class Issue(val issueId: String, val content: String) 
  1. GithubRepository 提供了各式各樣的 github API 操作,這些 API 都是最基本的操作,不會有商業邏輯在這個類別中。
  2. GithubService 包含了各種商業邏輯的操作,例如 searchIssue 就可能需要打 GithubRepository 的兩到三個 API ,再經過一連串的計算,最後返回結果。為了跟 GithubRepository 做互動,所以必須將實例以 setter 的方式做注入,同時這麼做也是為了讓這些 function 都是 Pure function
  3. 上面這兩個類別需要用的的領域資料結構,今天這只是個簡單的範例,所以這些資料結構跟都以簡單能夠示範為主,實際上會再更複雜一些。

Requirement

假設現在我要做一個機器人,想要對某些符合條件的 Issue 都加上 Comment ,那麼實作可能會像下面這樣:

fun robot(githubService: GithubService, githubRepository: GithubRepository) {
    val keyword = "master"
    val comment = "No more master/slave"

    // 1
    githubService.login("yanbin@hotmail.com", githubRepository)
        // 2
        .map { user -> githubService.searchIssue(user.userId, keyword, githubRepository) }
        .map { issues: List<Issue> ->
            issues.map { issue ->
                // 3
                githubService.addComment(issue.issueId, comment, githubRepository)
            }
        }
        // 4
        .fold(someFun = {
            // Do something
        }, noneFun = {
            // Do something for user not find
        })
}
  1. 為了拿到某個使用者的 Id ,就要藉由呼叫 githubService.login() 來做登入,由於這個 function 回傳的是一個 Maybe ,有可能結果是空的,為了對有值的情況下做操作,就要使用 map 這個 operator。
  2. 接續上一個結果,我們預期會拿到 User 這個資料結構,接下來就可以利用該資料的 userId 來做下一步的操作,使用關鍵字來搜尋該使用者的 Issue,這裡一樣會用到 GithubServiceGithubRepository
  3. 這裡出現了兩層的 map ,別慌張!第一層是對 Maybe 做操作,第二層是對 List 做操作,關注第二層即可,上一個結果回傳了 List ,這些是我們要搜尋的結果,最後就可以針對這些 Issue 一個一個加上 comment,就完成我們的需求了。最後這裡還是出現了 GithubServiceGithubRepository
  4. Maybe 的收尾,依照情況做不同的處理,可能是顯示對話框,也可能什麼都不做。

Reader

上面的程式碼中, GithubServiceGithubRepository 各出現了三次,其實寫久了會有點煩,會想要用更簡潔的方式來處理,而且為了要執行 robot ,還必須要有 GithubServiceGithubRepository 的實例才行,既然從參數注入這個方法這麼麻煩,現在我們來換另一種做法:

interface GithubService {
    // 為了簡化問題把 Maybe 拿掉了,關於這部分之後有機會會在回過頭來做給大家看
    fun login(email: String): (GithubRepository) -> User
    // 一樣為了簡化問題把 List 拿掉了,關於這部分在章節的最後會回過頭來做給大家看
    fun searchIssue(userId: String, keyword: String): (GithubRepository) -> Issue
    fun addComment(issueId: String, comment: String): (GithubRepository) -> Boolean
}

請看每個 function 的回傳型別,居然把型別往後移了!?還串起來變成了一個 function type ,這是什麼概念?其實這就是柯里化(curried),目前可以先不用在意柯里化是什麼,這概念在後面的章節會再詳細介紹。目前要在意的是,這個 function type 給予了我們一個額外的能力,一個不需要有 GithubRepository 實例的能力。這裡運用 lazy execution 的概念,在執行上述 GithubService 的任何 function 時,他們回傳的是一個 lambda function ( 型別為 (GithunRepository) -> T ),而這個 lambda function 可以再傳給一個 higher order function,之後就可以在任意的時間點執行這個 function,也可以在任意的時間點再注入 GithubRepository

接下來,我們還希望這些流程還是可以很清楚的表現在程式碼上面,我們的最終目的是一個清晰,好讀的程式碼:

githubService.login(email)
        .???{ user -> githubService.searchIssue(user.userId, keyword)}
        .???{ issue -> githubService.addComment(issue.issueId, comment)}

上面的這個流程是順的,看起來非常舒服,也不用看到 GithubRepository 了,但是這件事做得到嗎?我們有辦法組合這些 function 嗎?於是接下來就要輪到 Reader 出場了

class Reader<D, out A>(val run: (D) -> A) {

    inline fun <B> map(crossinline fa: (A) -> B): Reader<D, B> = Reader {
        d -> fa(run(d))
    }

    inline fun <B> flatMap(crossinline fa: (A) -> Reader<D, B>): Reader<D, B> = Reader {
        d -> fa(run(d)).run(d)
    }
}

Reader 的建構子是一個 function ,一個 input D 配上一個 output A ,D 對應到的就是需要的 Dependency , A 對應到的是目前正在使用的 Type 。這些剛好都可以取代上面的 (GithubRepository) -> UserReader<GithubRepository, User>(GithubRepository) -> IssueReader<GithubRepository, Issue>(GithubRepository) -> BooleanReader<GithubRepository, Boolean>

interface GithubService {
    fun login(email: String): Reader<GithubRepository, User>
    fun searchIssue(userId: String, keyword: String): Reader<GithubRepository, List<Issue>>
    fun addComment(issueId: String, comment: String): Reader<GithubRepository, Boolean>
}

接下來再來看看 map 做的是什麼事, map 建立了一個新的 Reader , 裡面做的事情是 fa 這個 function 與 run - 建構子傳進來的參數來做組合,所以 function 組合的結果會是一個 Type B 的變數,於是就會得到一個 Reader<D, B> 的結果。寫的更加直覺一點就是 Reader<D, A> map (A -> B) => Reader<D, B> ,那這不是跟 List, Maybe 還有 Either 是一樣的模式嗎? 所以一樣的,也會有一個 flatMapReader<D, A> flatMap (A -> Reader<D, B>) => Reader<D, B>

好了,在回過頭來看看我們希望的樣子吧:

githubService.login(email)
        .???{ user -> githubService.searchIssue(user.userId, keyword)}
        .???{ issue -> githubService.addComment(issue.issueId, comment)}

來分析一下每一行的型別分別是什麼,第一行很簡單是 Reader<GithubRepository, User> ,第二行是一個 higher order function ,裡面只有一個參數,型別是 function type,而這個 function 呢,輸入是 User ,輸出是 Reader<GithubRepository, Issue> ,所以這個 function type 的型別是 (User) -> Reader<GithubRepository, Issue> ,依此類推,第三行的 function type 的型別是 (Issue) -> Reader<GithubRepository, Boolean> ,所以答案已經很明顯了, flatMap 剛好就是一個 higher order function ,唯一的參數還是一個輸入 T 配上 Reader 的 Function Type , ??? 要填入的正是 flatMap

val result: Reader<GithubRepository, Boolean> = githubService.login(email)
        .flatMap{ user -> githubService.searchIssue(user.userId, keyword)}
        .flatMap{ issue -> githubService.addComment(issue.issueId, comment)}

// When we have githubReposiotry...
val boolean: Boolean = result.run(githubRepository)

小結

跟往常不一樣的,今天介紹了一個用 "function" 當做“容器” 的 Reader ,正因為有了這個 function ,才使得我們有能力延後做 Dependency Injection ,不用在一開始就提供。同時今天的範例也強調了一個重點,“Functional programming 應該要讓流程完整呈現,不應該讓其他的工程師看不懂”。

但是實際情況還是複雜很多,像今天的範例就把 Maybe 跟 List 拿掉了,萬一我真的需要他們怎麼辦?別擔心,在後面的章節會看到的。這邊先提前爆雷一下,像是 List 就可以這樣做:

interface GithubService {
    fun login(email: String): Reader<GithubRepository, User>
    fun searchIssue(userId: String, keyword: String): Reader<GithubRepository, List<Issue>>
    fun addComment(issueId: String, comment: String): Reader<GithubRepository, Boolean>
}

val keyword = "master"
val comment = "No more master/slave"

fun robot() {
	val addCommentReader = githubService.login("yanbin@hotmail.com")
	    .flatMap { user -> githubService.searchIssue(user.userId, keyword) }
	    .flatMap { issues: List<Issue> ->
	        issues.map { issue ->
	            githubService.addComment(issue.issueId, comment)
	        }.liftReader()
	    }
	
	val result: List<Boolean> = addCommentReader.run(githubRepository)
}

fun <D, A> List<Reader<D, A>>.liftReader(): Reader<D, List<A>> {
    ...
}

searchIssue 之後回傳的型別是 Reader<GithubRepository, List<Issue>> 。但是在下一行中,如果對 issues 使用 map 卻會得到 List<Reader<GithubRepository, Boolean>> ,這不是我們喜歡看到的型別, 如果是 Reader 在前面會好處理很多。所以 liftReader 就將容器的順序調換過來了!如此一來就可以專心的做 Dependency Injection 而不用擔心 List 。

還有,這邊故意不實作應該知道我的用意了...沒錯!這邊的實作留給大家來挑戰看看!

References:

https://jorgecastillo.dev/kotlin-dependency-injection-with-the-reader-monad


上一篇
Lenses
下一篇
Composition, Abstraction and Principles
系列文
Functional Programming in Kotlin30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言