18 June 2017

Kotlin 最近紅啊!對這個語言我 2011 年時就有在看了,不過後來放生,因為 Java8 真是不錯用。直到它出了 1.0 後,它宣稱向前相容和永續維護,有了這項我最看重的承諾,它才進入我的口袋名單,計畫開始試用。

真正開始導入的時間點是 Kotlin 出 1.1 版,1.1 版的重大變更是加入了 coroutine,那時我已寫過一陣子 dart async/await 功能,認定 async/await 必定是 client side 開發未來的趨勢,而且真的是很好寫。1.1 加入了 coroutine 我就掉進這個坑了。

結果 coroutine 試了之後發現還在實驗階段而已,只好又先放著,不過我們 Android 的開發已經開始寫 Kotlin 了。又過了幾個月 Google I/O 發表了 Google 要將 Kotlin 作為 Android 的官方語言…

哇塞!This is huge!!

突然間我的信心大增,原本只是嘗鮮跟個風而已,現在可好,時代的輪子開始轉動了,而且還大轉特轉!我給自己和團隊定了一個潛規則 -- 從現在開始所有新的 .java 檔都要是 .kt

一切都從 data class 開始

好了,導入的廢話說完了,我們來進入正題。Kotlin 有個最直覺最簡單的功能,是導入的團隊和開發者第一個要學,就是 data class

data class Account(val id:Long, val name:String)

上面是個帳號 data class,裡面有兩個基本欄位,然後 compiler 會替你生成 property 和 equals()/hashCode()/copy() 等三個 method。相比 Java 的實作,大概是 1:20 這樣的程式碼行數比,一口氣少了 20 行以上啊。

這… 能簡化成這樣還不狂用嗎?我肚子裡的程式蟲開始大鬧了!

ps. 注意本文不會討論 data class 的語法和簡介,這篇的主題是運用 data class 後,引發的一連串程式設計的 化學變化

data class apply to DTO

首先是 DTO (Data Transfer Object),這個是 pattern 是處理資料 serialize/deserialize 時常用的技巧,無論是遠端的 API 呼叫,或是儲存到資料庫,如果你選用的 json library 是 jackson 。你可以加上 kotlin module:

gradle:

compile "org.jetbrains.kotlin:kotlin-reflect:1.1"
compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.8.8"

jackson ObjectMapper 設定一下:

ObjectMapper().registerModule(KotlinModule())

接下來的你可以簡單的 deserialize 你的 data class

val json = """
{
  "id": 14,
  "name": "ingram"
}"""
val account = objectMapper.readValue(json, Account::class.java)

咦?好像沒什麼特別的啊?!有什麼好提的?重點在如果用 Java 寫 Accont 這個 DTO 會是:

public class Account {
  @JsonCreator
  public Account(
    @JsonProperty("id") long id, 
    @JsonProperty("name") String name) {...}     
}

你必須一個個替所有的欄位加上 annotation,因為 Java 並不會保留 parameter 的名字!所以 reflection 時拿不到。這實在是太痛苦了,尤其是 DTO 的物件很多時。搞到後來都想自己寫工具產生了。

不過 Kotlin 就不一樣了,Kotlin compile 時會加寫 meta data,將 parameter name 另外存,所以可以用 kotlin-reflect 撈出來,而這就是 jackson kotlin module 裡做的事。Java8 也可以加寫 parameter name 啦,但這個在 Android 不能用。

這裡只是以 jackson 為例,其他的 library 應該也有做類似的事,用了 Kotlin 就是大大的省功啊!

個人認為光是 data class 套用在 DTO 就值得導入 Kotlin 了。如果你們團隊對導入 Kotlin 有所疑慮,那就先限定使用 data class 在 DTO 之類的 class 就好了,因為就算 Kotlin 真的不合預期,要將 data class 全轉回 .java 並不會花多少時間。

data class apply to Entity

DTO 很自然的可以套用 data class。那麼 Entity 呢?Entity 是指有識別 id 的物件:

java JPA:

@Entity
public class AccountEntity {
  @Id
  private long id;
  private String name;
  public boolean equals(Object that) {
    if (that == null) return false;
    return that.class == AccountEntity.class 
         ? id == ((AccountEntity)that).id
         : false;
  }
  public int hashCode() { return Long.hashCode(id); }
}

如你所見 Entity 在 JPA 的環境下可能欄位是 mutable 的,然後 equals 只要是識別 id 相同就是相等的。

data class 可以用在 JPA Entity 嗎?說實在的我沒研究,data class 和 JPA 相衝的點是 data class 沒有 default constructor。不過你可以加個 plugin 解決

gradle:

apply plugin: "kotlin-jpa"

plugin 會替你生 default constructor 給 Entity 用,而這個 constructor 只有 reflection 才能看的到。不過 data class 預設的 equals/hashCode 可能不是你要的 (它會比全部的欄位,而不是只有 id),你還是得自己 override。

至於我個人的做法比較極端了,因為我已經選擇不再用 ORM framework 了。我選擇用 Spring jdbc template 之類的簡化過工具來處理 ORM,而不是一個具備複雜 session/cache/life cycle 的 framework 像是 JPA/Hibernate。

從 JPA 解脫後我的 Entity 一直都是 Immutable Object,也讓所有欄位 equals,換句話說就是個 data class

data class AccountEntity(val id:Long, val name:String)

哇,跟 DTO 很像啊,在實務上用 immutable Entity 有沒有問題?,而所有欄位都納入 equals 有沒有問題?我的答案都是無。Immutable Entity 其實讓管理物件的 lifecycle 簡單很多,沒有 mental cost (意思就是你看的這段程式碼,看著這個物件,還要去想它背後是不是 detached session,還是它欄位剛剛有沒有被改過什麼的,一些隱藏在背後的成本)。我寫了幾年現在比較傾向這種設計了,套上 Kotlin 的 data class 更是如魚得水。

我建議各位可以試試套用 immutable 的想法在 Entity 上,也就是換上 data class。如果你是用 JPA,記得要加 plugin。

題外話:從 kotlin-jpa plugin 的設計就可以了解 kotlin 是相當務實的語言。

什麼?JPA 不能用嗎?那就開個後門讓你用吧!

很多語言設計師都很在意 purity,不會想開後門這種髒東西。kotlin 開發團隊的想法就是 -- "沒關係!很難用我們就改吧!" 骨子裡是商業 IDE 的開發商就是不一樣。

How data class affect your API design

接下來討論的是 data class 如何影響你的 API 設計,上面提的都是 class 本身,如果在 method (API) 這個層級使用 data class 會有什麼化學反應呢?

class Messenger {
  fun sendText(receiverId:Long, text:String)
  fun sendData(receiverId:Long, data:Map<String, String>)
  fun broadcastText(topic:String, text:String)
  fun broadcastData(topic:String, data:Map<String, String>)
}

//example usage:
messenger.sendText(13L, "Hello world")
messenger.broadcastText("notice", "Hello world")

大概解說一下,這個 kotlin class 上有四個 method,從結構來看,它可以送文字訊息給收的人 sendText(receiver, text) ,也可以送 data sendData(receiver, data) 。然後它也有廣播給某個主題文字訊息和 data 的功能。(broadcast* ,有訂閱這個 topic 的人都收的到這個廣播)。

ok,應該不會很難懂吧?

自從有了 data class 這個武器之後,我的設計開始變成這樣了:

sealed class Destination {
  data class Receiver(val id:Long): Destination()
  data class Topic(val name:String): Destination()

  //後面的 `:Destination()` 是 kotlin 繼承的語法
}

class Messenger {
  fun sendText(destination:Destination, text:String)
  fun sendData(destination:Destination, data:Map<String, String>)
}

//example usage:
messenger.sendText(Receiver(13L), "Hello world")
messenger.sendText(Topic("notice"), "Hello world")

sealed class 是指這個 class 只有以下這些我定義的 subclass,別人不能再繼承,換句話說,Destination 這個 class 一定只會有兩種 subclass,一個是Receiver, 一個是 Topic 。有 sealed 這個功能可以確保在 compile 時期就能抓到不可預期的 subclass。沒有這個保護,用戶或是接手維護的人,如果亂繼承亂用 API,變成要等到 runtime 才爆掉才發現有 bug。

這個範例裡,我將 receiver 和 topic 給 union 了起來,抽像成一個 Destination class 來代表。其實這種 API 設計在各大 message broker 的 API 都看的到,好像也沒什麼特別的吧?不過 data class 沒幾行就能實作出 union type,對比 Java 需要加寫的程式碼,使用 Kotlin 語言會有點鼓勵這樣的 API 設計。

ps. 如果語言有 union type 的功能,那可能 API 會變成吧

//這是假想中的 union type 功能: (Long|String)
fun sendText(destination:(Long|String), text:String)

不過 kotlin 沒打算加 union type,所以就是 sealed data class 拿來替代

data class 魔人

一旦起了 union type 的頭,就一發不可收拾了:

sealed class Payload {
  data class Text(val content:String): Payload()
  data class Data(val json:Map<String,String>): Payload()
}

class Messenger {
  fun send(destination:Destination, payload:Payload)
}

//example usage:
messenger.send(Receiver(13L), 
               Text("hello world"))
               
messenger.send(Topic("notice"), 
               Data(mapOf("url" to "4gamers.com.tw"))

//`mapOf()` 是 kotlin 裡類似 map literals 的東西

哇!傳送的東西也抽出來變 Payload,API 只剩一個 method 了。

hmmm.... 好像開始走火入魔了喔?!但先這樣吧。

之後,隨著需求的增加,這個 messenger 要可以設定發出去的訊息會不會在手機聲響,會不會彈 notification 小窗出來,要不要是私密的留言,等等功能…

class Messenger {
  fun send(destination:Destination, 
           payload:Payload,
           soundFile:String?,
           showNotification:Boolean,
           secret:Boolean)
}

這時候你就會感謝自己當初把四個 method 合併成一個了,因為你只要 refactor 一個 method 就行。當然啦,不是把 method 合併都一定是對的,例如其實用 API 的開發者寫起來有點不順,他要額外使用 Destination 和 Payload 兩個 class ,而不是僅僅是 messenger 上的不同 method。

結束了嗎?no no no,data class 魔人哪有這麼遜,讓我們再繼續看下去…

Design smell: Too many parameters

從歷史的需求變更來看,這個 method 怎麼一直橫向發展啊,意即選項越來越多,parameters 越來越長。寫程式久的人自然而然的可以聞到一股 臭味 了。要改善這個問題就是套用 Parameter Object refactor:

第一種解:

class Messenger {
  data class Option(
     val soundFile:String? = null,
     val showNotification:Boolean = false,
     val secret:Boolean = false
  )
  fun send(destination:Destination, 
           payload:Payload,
           option:Option)
}
//example usage:
messenger.send(Receiver(13L), 
               Text("hello world"),
               Option(secret = true))

我們將額外的選項抽到單一 data class 去了,未來如果 messenger 又要擴充功能,那就加到 Option 裡面的欄位,這樣 send() 的 signature 就不會一直改來改去,也不會有太長參數的臭味。

第二種解:

class Messenger {
  data class Event(
     val destination:Destination, 
     val payload:Payload,
     val soundFile:String? = null,
     val showNotification:Boolean = false,
     val secret:Boolean = false
  )
  fun send(event:Event)
}

// example usage:
messenger.send(Event(
                  Receiver(13L), 
                  Text("hello world"),
                  secret = true)
              )

第二種解法是整團 parameter 全抽到一個 Event 的 data class 去了。這比第一種解更極端一點。

問題來啦,如果是你,你會選哪個?

data class give you more options

先不論要選哪個,從這個 messenger 的 API 套用了 data class 後,它的特徵和演化都不一樣了。這在原 Java 界是不太可能發生的,因為 Java 裡要寫 union type、要寫 data class 太繁鎖了,大部份的 Java 開發者都是選擇用 method overload 來解決吧。

在 kotlin 裡,你多了很多可能性,因為開 data class 太便宜。

上面兩種解法其實各有優點,第一種 Option class 是不錯的解法,API 很清楚很容易了解。而第二種,你注意到了嗎,它是 Command pattern,如果 API refactor 成 command pattern 後續就會享受它的好處 (例如可以送不同 event 了,或是 API 的用戶方便用 event-driven 的方式運用你的 API)。

Kotlin data class 不只是在 DTO/Entity 上有用處,它會開始影響整個程式碼的 API 設計和風格

小結

本文討論了 Kotlin 的 data class 在實務上運用時,產生的優點和一些問題。這些內容是我寫了一兩個月開始真正碰到的。一開始導入 data class 就是 DTO/Entity 用的很爽,用太爽接下來我就魔人化了,到處狂用 data class。而那個 Messenger 其實是我寫 Firebase Cloud Message 時遇到的設計考量。

在 API 導入 data class,最大的目標還是提供 compile 時期的保護。上述的 Destination 或是 Payload 有型別保護,使用者不能亂傳任何資料,亂傳就是 compile 不過,而不是等到 runtime 才爆掉。

考試

上面的 Messenger 的例子裡,最後的兩個解都不錯,不過呢… 如果 secret 這個選項,只有在單一人收訊息才有意義時怎麼辦?(你想,訊息會廣播到主題內的所有人,其實這設為私密訊息一點意義也沒有吧?通常是送給個人才有意義,送給 Receiver(id))。

這個需求一來,option 裡面放secret 欄位就不大對了,如果有人送廣播訊息但卻設了 secret=true 那就是 runtime 時期噴 exception,沒辦法 compile 時期就先抓到這個 bug。

此題怎解?

kaif.io 討論這篇文章


回響

可以用 Tag <I>、<B>,程式碼請用 <PRE>