Data layer implementation (1)

在上一篇,我们把 Ktor client 加到 Dagger 的 object graph 内。现在我们就继续写 data layer 部分。

跨 layer 共用部分

不过在继续之前,我们要先准备一些通用的 enum。这些 enum 分别是用来表示路綫、车站和语言。因为这次的示范 app 所用到的车站和路綫的数量有限,我们就简化用 enum 写死在 app 入面就算了,但如果数量多的话或许会改用 SQLite database 之类去储存它们。在这种情况下,我们一般都会每个 layer 都有对应的 data class 表示,而不会好像现在把 data class/enum 放在 common 的地方。

enum class Line(val zh: String, val en: String) {
    AEL("机场快綫", "Airport Express"),
    TCL("东涌綫", "Tung Chung Line"),
    TML("屯马綫", "Tuen Ma Line"),
    TKL("将军澳綫", "Tseung Kwan O Line"),
}
enum class Station(val zh: String, val en: String) {
    UNKNOWN("", ""),

    HOK("香港", "Hong Kong"),
    KOW("九龙", "Kowloon"),
    OLY("奥运", "Olympic"),
    NAC("南昌", "Nam Cheong"),
    LAK("茘景", "Lai King"),
    TSY("青衣", "Tsing Yi"),
    SUN("欣澳", "Sunny Bay"),
    TUC("东涌", "Tung Chung"),
    AIR("机场", "Airport"),
    AWE("博览馆", "AsiaWorld-Expo"),

    WKS("乌溪沙", "Wu Kai Sha"),
    MOS("马鞍山", "Ma On Shan"),
    HEO("恒安", "Heng On"),
    TSH("大水坑", "Tai Shui Hang"),
    SHM("石门", "Shek Mun"),
    CIO("第一城", "City One"),
    STW("沙田围", "Sha Tin Wai"),
    CKT("车公庙", "Che Kung Temple"),
    TAW("大围", "Tai Wai"),
    HIK("显径", "Hin Keng"),
    DIH("钻石山", "Diamond Hill"),
    KAT("启德", "Kai Tak"),
    SUW("宋皇台", "Sung Wong Toi"),
    TKW("土瓜湾", "To Kwa Wan"),
    HOM("何文田", "Ho Man Tin"),
    HUH("红磡", "Hung Hom"),
    ETS("尖东", "East Tsim Sha Tsui"),
    AUS("柯士甸", "Austin"),
    MEF("美孚", "Mei Foo"),
    TWW("荃湾西", "Tsuen Wan West"),
    KSR("锦上路", "Kam Sheung Road"),
    YUL("元朗", "Yuen Long"),
    LOP("朗屏", "Long Ping"),
    TIS("天水围", "Tin Shui Wai"),
    SIH("兆康", "Siu Hong"),
    TUM("屯门", "Tuen Mun"),

    NOP("北角", "North Point"),
    QUB("鰂鱼涌", "Quarry Bay"),
    YAT("油塘", "Yau Tong"),
    TIK("调景岭", "Tiu Keng Leng"),
    TKO("将军澳", "Tseung Kwan O"),
    HAH("坑口", "Hang Hau"),
    POA("宝琳", "Po Lam"),
    LHP("康城", "LOHAS Park"),
}
enum class Language {
    CHINESE,
    ENGLISH,
}

Domain layer 准备工作

虽然本篇题目是 data layer,但因为 data layer 需要实作 domain layer 的 interface。所以我们要先定义好那些东西才能回到 data layer。

首先是 EtaRepository 的部分,里面有两个 function。一个是提供路綫和车站的关连,用来做拣选车站的 UI;另一个是 call API 取得某路綫的某车站的抵站时间。

interface EtaRepository {
    fun getLinesAndStations(): Map<Line, Set<Station>>
    suspend fun getEta(language: Language, line: Line, station: Station): EtaResult
}

getLinesAndStations 是普通 function 但 getEta 是 suspending function 是因为 getLinesAndStations return value 就是一个写死的 Map,不会有耗时的工作。而 getEta 会 call API endpoint(非同步),所以就用 suspending function。

由於我们目标是显示班次的目的地名称、月台编号和倒数分钟,所以不能直接拿 response 来用,要经过处理才可以拿去 UI 那边用。

如果 response 的 status0 的话,我们就用另一个 data class 表示目前綫路有事故。

至於 HTTP 429、HTTP 500、其余的例外情况我们会另外用 object 和 data class 表示,这样就可以不用 throw exception 去调用那一边。

我们会把这几款 class 都继承同一个 sealed interface EtaResult,用来表达这个 API 所有可能输出的结果。而交给 domain layer 调用的 method 会是一个 suspend fun 并回传那个 sealed interface,那样之後就算找不到 API 文档只看 code 大概都会知道有甚麽情景。

sealed interface EtaResult {
    data class Success(
        val schedule: List<Eta> = emptyList(),
    ) : EtaResult {
        data class Eta(
            val direction: Direction = Direction.UP,
            val platform: String = "",
            val time: Instant = Instant.EPOCH,
            val destination: Station = Station.UNKNOWN,
            val sequence: Int = 0,
        ) {
            enum class Direction { UP, DOWN }
        }
    }

    object Delay : EtaResult

    data class Incident(
        val message: String = "",
        val url: String = "",
    ) : EtaResult

    object TooManyRequests : EtaResult

    object InternalServerError : EtaResult

    data class Error(val e: Throwable?) : EtaResult
}

有部分 class 用了 object 是因为它们没有 field 在储东西,所以全个 app 只用同一个 instance 都没问题。

实作接驳 API endpoint

由於先前已经准备了 EtaResponse data class,我们可以把上一篇的 Ktor client 稍为改小许就可以完成今天要做的部分。

class EtaRepositoryImpl @Inject constructor(
    private val httpClient: HttpClient,
    private val etaResponseMapper: Mapper<HttpResponse, EtaResult>,
) : EtaRepository {
		override fun getLinesAndStations(): Map<Line, Set<Station>> = linkedMapOf(
        AEL to linkedSetOf(HOK, KOW, TSY, AIR, AWE),
        TCL to linkedSetOf(HOK, KOW, OLY, NAC, LAK, TSY, SUN, TUC),
        TML to linkedSetOf(
            WKS, MOS, HEO, TSH, SHM, CIO, STW, CKT, TAW, HIK, DIH, KAT, SUW, TKW,
            HOM, HUH, ETS, AUS, MEF, TWW, KSR, YUL, LOP, TIS, SIH, TUM,
        ),
        TKL to linkedSetOf(NOP, QUB, YAT, TIK, TKO, HAH, POA, LHP),
    )

    override suspend fun getEta(language: Language, line: Line, station: Station) = try {
        val response =
            httpClient.get<HttpResponse>("https://rt.data.gov.hk/v1/transport/mtr/getSchedule.php") {
                parameter("line", line.name)
                parameter("sta", station.name)
                parameter(
                    "lang", when (language) {
                        Language.CHINESE -> "TC"
                        Language.ENGLISH -> "EN"
                    }
                )
            }
        // 这部分我们下一篇会做
        etaResponseMapper.map(response)
    } catch (e: Throwable) {
        EtaResult.Error(e)
    }
}

getLinesAndStations 的实作没甚麽特别,就是一个 immutable 的 Map。至於 getEta 其实就分为两个部分:用 Ktor client call API 并取得 response 和把 API response 变成 domain layer 的 EtaResult(亦即是这个 function 的 return type)。

第一部分我们会用到上一篇所准备的 DataModule 入面的 HttpClient,我们只需要在 EtaRepositoryImpl constructor 加上 @Inject 并在 constructor 加上 HttpClient 的 parameter 就能让 Dagger 帮我们取得 dependency。etaResponseMapper 我们会在下一篇处理。回到 getEta 里面,第一句那个 get 基本上跟上一篇的写法一样,只是这次 get 那个 generic type 用了 HttpResponse 而不是 EtaResponse。这是因为我们要取得 HTTP response 的 status code。取得 response 後就交去 mapper 转换成 EtaResult。另外,在整句调用 Ktor client 的部分包裹着 try-catch block,为的就是把不能连接网路之类的 exception 接住,那调用 repository 的一方不用再包 try-catch block,而且用了 sealed interface 亦能保证对方知悉这个情景从而写对应的 code 处理。

在完成 EtaRepositoryImpl 後,我们要把 EtaRepositoryEtaRepositoryImpl 的关连告诉给 Dagger 知道,否则 Dagger 看到有其他地方依赖 EtaRepository 时就不知道要给它甚麽实作。我们会用 @Binds 向 Dagger 指明凡是其他地方依赖 EtaRepository 我们都会给它 instantiate 一个新的 EtaRepositoryImpl@Binds@Provides 不同的地方是 @Binds@Provides 的简化版,如果要指明依赖某个 abstract class 或 interface 时要 instantiate 那个 concrete implementation 的话,我们就可以用 @Binds 写一个 abstract function,Dagger 就会帮我们补上那个 abstract function 的实作。注意的是如果那个 concrete implementation 的 class 的 constructor 也是需要用到其他 dependency,你的 constructor 亦都需要加上 @Inject,这样 Dagger 才能替你自动替你做 dependency injection。但如果那个 class 是人家写的(即是 library 提供你又不能帮它加 @Inject)的话,你就只能用 @Provides 并在 method parameter 自行补上要用到的 dependency,然後在 method body 用那些 method parameter 来 instantiate 那个 class 并将它 return 出去,就像之前准备 Ktor client 般的做法。

@Module
@InstallIn(SingletonComponent::class)
interface DataModule {

    @BindsOptionalOf
    fun bindLogging(): HttpClientFeature<Logging.Config, Logging>

    // 本节新加的部分
    @Binds
    fun bindEtaRepository(impl: EtaRepositoryImpl): EtaRepository

    // 之前准备 Ktor client/OkHttp 所写的东西
    companion object {
        // ...
    }
}

跟 Retrofit 的比较

如果是用 OkHttp 及 Retrofit 的话,写法会比较简洁一点。因为那些 parameter 和 API endpoint 的 URL 都是写在 interface function 的 Retrofit 专门 annotation。现在我们用 Ktor client 感觉上会像直接用 OkHttp 发送 HTTP request 般,但有一点不同是 Ktor client 还是有跟 JSON deserialization library 有整合,只需一句 httpClient.get<EtaResponse> 就能拿到 deserialize 後的 object。如果你的 app 要驳不同的 API,每个的 base URL 都不相同的话或许用 Ktor client 这种写法比起 Retrofit 要写 interface 还要方便。

下一篇我们会实作 EtaResponseMapper


<<:  DAY10-小型成果发表

>>:  分散式资料库:分散式策略

JavaScript Day20 - AJAX(2)

ES6:fetch fetch():Fetch API 提供了一个能获取包含跨网路资源在的资源介面,...

我的第一份实习

前面有提到,我在大一的时候就有花费大量的时间打工,到了升上大二的暑假前我也开始思考这样到底是不是对的...

Day 7 - 计算属性和监听器

计算属性(Computed Property) 当我们今天想针对某一变数做运算或是处理时,可以透过模...

IT铁人DAY 26-Memento 备忘录模式

  今天要学习的模式我觉得很有趣,学完以後很常拿它用来做 undo、redo 的功能,因为它的功能就...

【Day 14】- 今天来实作一个键盘监听器

Agenda 资安宣言 测试环境与工具 技术原理与程序码 References 下期预告 资安宣言 ...