你好,我是朱涛,又到了实战环节。
在前面几节课当中,我们一起学习了Kotlin的委托、泛型、注解、反射这几个高级特性。那么今天这节课,我们将会运用这些特性,来写一个Kotlin版本的HTTP网络请求框架。由于它是纯Kotlin开发的,我们就把它叫做是KtHttp吧。
事实上,在Java和Kotlin领域,有许多出色的网络请求框架,比如 OkHttp、Retrofit、Fuel。而我们今天要实现的KtHttp,它的灵感来自于Retrofit。之所以选择Retrofit作为借鉴的对象,是因为它的底层使用了大量的泛型、注解和反射的技术。如果你能跟着我一起用泛型、注解、反射来实现一个简单的网络请求框架,相信你对这几个知识点的认识也会更加透彻。
在这节课当中,我会带你从0开始实现这个网络请求框架。和往常一样,为了方便你理解,我们的代码会分为两个版本:
另外,在正式开始学习之前,我也建议你去clone我GitHub上面的KtHttp工程:https://github.com/chaxiu/KtHttp.git,然后用IntelliJ打开,并切换到start分支跟着课程一步步敲代码。
在正式开始之前,我们还是先来看看程序的运行效果:
在上面的动图中,我们通过KtHttp请求了一个服务器的API,然后在控制台输出了结果。这其实是我们在开发工作当中十分常见的需求。通过这个KtHttp,我们就可以在程序当中访问任何服务器的API,比如GitHub的API。
那么,为了描述服务器返回的内容,我们定义了两个数据类:
// 这种写法是有问题的,但这节课我们先不管。
data class RepoList(
var count: Int?,
var items: List<Repo>?,
var msg: String?
)
data class Repo(
var added_stars: String?,
var avatars: List<String>?,
var desc: String?,
var forks: String?,
var lang: String?,
var repo: String?,
var repo_link: String?,
var stars: String?
)
除了数据类以外,我们还要定义一个用于网络请求的接口:
interface ApiService {
@GET("/repo")
fun repos(
@Field("lang") lang: String,
@Field("since") since: String
): RepoList
}
在这个接口当中,有两个注解,我们一个个分析:
也许你会好奇,GET、Field这两个注解是从哪里来的呢?这其实也是需要我们自己定义的。根据上节课学过的内容,我们很容易就能写出下面的代码:
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class GET(val value: String)
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Field(val value: String)
从这段代码里我们可以看出,GET注解只能用于修饰函数,Field注解只能用于修饰参数。另外,这两个注解的Retention都是AnnotationRetention.RUNTIME,这意味着这两个注解都是运行时可访问的。而这,也正好是我们后面要使用的反射的前提。
最后,我们再来看看KtHttp是如何使用的:
fun main() {
// ①
val api: ApiService = KtHttpV1.create(ApiService::class.java)
// ②
val data: RepoList = api.repos(lang = "Kotlin", since = "weekly")
println(data)
}
上面的代码有两个注释,我们分别来看。
Class<T>
,返回值类型是ApiService。这就相当于创建了ApiService这个接口的实现类的对象。看到这里,你也许会好奇,KtHttpV1.create()是如何创建ApiService的实例的呢?要知道ApiService可是一个接口,我们要创建它的对象,必须要先定义一个类实现它的接口方法,然后再用这个类来创建对象才行。
不过在这里,我们不会使用这种传统的方式,而是会用动态代理,也就是JDK的Proxy。Proxy的底层,其实也用到了反射。
不过,由于这个案例涉及到的知识点都很抽象,在正式开始编写逻辑代码之前,我们先来看看下面这个动图,对整体的程序有一个粗略的认识。
现在,相信你大概就知道这个程序是如何实现的了。下面,我再带你来看看具体的代码是怎么写的。
这里我要先说明一点,为了不偏离这次实战课的主题,我们不会去深究Proxy的底层原理。在这里,你只需要知道,我们通过Proxy,就可以动态地创建ApiService接口的实例化对象。具体的做法如下:
fun <T> create(service: Class<T>): T {
// 调用 Proxy.newProxyInstance 就可以创建接口的实例化对象
return Proxy.newProxyInstance(
service.classLoader,
arrayOf<Class<*>>(service),
object : InvocationHandler{
override fun invoke(proxy: Any?, method: Method?, args: Array<out Any>?): Any {
// 省略
}
}
) as T
}
在上面的代码当中,我们在create()方法当中,直接返回了Proxy.newProxyInstance()这个方法的返回值,最后再将其转换成了T类型。
那么,newProxyInstance()这个方法又是如何定义的呢?
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h){
...
}
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
从上面的代码当中,我们可以看到,最后一个参数,InvocationHandler其实是符合SAM转换要求的,所以我们的create()方法可以进一步简化成这样:
fun <T> create(service: Class<T>): T {
return Proxy.newProxyInstance(
service.classLoader,
arrayOf<Class<*>>(service)
) { proxy, method, args ->
// 待完成
} as T
}
那么到这里,我们程序的基本框架也就搭建好了。
细心的你一定发现了,我们程序的主要逻辑还没实现,所以接下来,我们就一起看看上面那个“待完成”的InvocationHandler,这个Lambda表达式应该怎么写。这个换句话说,也就是Proxy.newProxyInstance(),会帮我们创建ApiService的实例对象,而ApiService当中的接口方法的具体逻辑,我们需要在Lambda表达式当中实现。
好了,让我们回过头来看看ApiService当中的代码细节:
interface ApiService {
// 假设我们的baseurl是:https://baseurl.com
// 这里拼接结果会是这样:https://baseurl.com/repo
// ↓
@GET("/repo")
fun repos(
// Field注解当中的lang,最终会拼接到url当中去
// ↓ ↓
@Field("lang") lang: String, // https://baseurl.com/repo?lang=Kotlin
@Field("since") since: String // https://baseurl.com/repo?lang=Kotlin&since=weekly
): RepoList
}
从代码注释中可以看出来,其实我们真正需要实现的逻辑,就是想办法把注解当中的值/repo、lang、since取出来,然后拼接到URL当中去。那么,我们如何才能得到注解当中的值呢?
答案自然就是我们在上节课学过的:反射。
object KtHttpV1 {
// 底层使用 OkHttp
private var okHttpClient: OkHttpClient = OkHttpClient()
// 使用 Gson 解析 JSON
private var gson: Gson = Gson()
// 这里以baseurl.com为例,实际上我们的KtHttpV1可以请求任意API
var baseUrl = "https://baseurl.com"
fun <T> create(service: Class<T>): T {
return Proxy.newProxyInstance(
service.classLoader,
arrayOf<Class<*>>(service)
// ① ②
// ↓ ↓
) { proxy, method, args ->
// ③
val annotations = method.annotations
for (annotation in annotations) {
// ④
if (annotation is GET) {
// ⑤
val url = baseUrl + annotation.value
// ⑥
return@newProxyInstance invoke(url, method, args!!)
}
}
return@newProxyInstance null
} as T
}
private fun invoke(url: String, method: Method, args: Array<Any>): Any? {
// 待完成
}
}
在上面的代码中,一共有6个注释,我们一个个看。
api.repos("Kotlin", "weekly")
”当中的"Kotlin"
和"weekly"
。接下来,我们再来看看invoke()当中的“待完成代码”应该怎么写。
private fun invoke(url: String, method: Method, args: Array<Any>): Any? {
// ① 根据url拼接参数,也就是:url + ?lang=Kotlin&since=weekly
// ② 使用okHttpClient进行网络请求
// ③ 使用gson进行JSON解析
// ④ 返回结果
}
在上面的代码中,我们的invoke()方法一共分成了四个步骤,其中的③、④两个步骤其实很容易实现:
private fun invoke(url: String, method: Method, args: Array<Any>): Any? {
// ① 根据url拼接参数,也就是:url + ?lang=Kotlin&since=weekly
// 使用okHttpClient进行网络请求
val request = Request.Builder()
.url(url)
.build()
val response = okHttpClient.newCall(request).execute()
// ② 获取repos()的返回值类型 genericReturnType
// 使用gson进行JSON解析
val body = response.body
val json = body?.string()
// 根据repos()的返回值类型解析JSON
// ↓
val result = gson.fromJson<Any?>(json, genericReturnType)
// 返回结果
return result
}
继续看,经过我们的分解,现在的问题变成了下面这样:
api.repos("Kotlin", "weekly")
”这个方法当中的"Kotlin"
和"weekly"
,将其与URL进行拼接得到:url + ?lang=Kotlin&since=weekly
我们来看看最终的代码:
private fun invoke(path: String, method: Method, args: Array<Any>): Any? {
// 条件判断
if (method.parameterAnnotations.size != args.size) return null
// 解析完整的url
var url = path
// ①
val parameterAnnotations = method.parameterAnnotations
for (i in parameterAnnotations.indices) {
for (parameterAnnotation in parameterAnnotations[i]) {
// ②
if (parameterAnnotation is Field) {
val key = parameterAnnotation.value
val value = args[i].toString()
if (!url.contains("?")) {
// ③
url += "?$key=$value"
} else {
// ④
url += "&$key=$value"
}
}
}
}
// 最终的url会是这样:
// https://baseurl.com/repo?lang=Kotlin&since=weekly
// 执行网络请求
val request = Request.Builder()
.url(url)
.build()
val response = okHttpClient.newCall(request).execute()
// ⑤
val genericReturnType = method.genericReturnType
val body = response.body
val json = body?.string()
// JSON解析
val result = gson.fromJson<Any?>(json, genericReturnType)
// 返回值
return result
}
上面的代码一共涉及五个注释,它们都是跟注解与反射这两个知识点相关的。
@Field("lang")
、@Field("since")
。说实话,动态代理的这种模式,由于它大量应用了反射,加之我们的代码当中还牵涉到了泛型和注解,导致这个案例的代码不是那么容易理解。不过,我们其实可以利用调试的手段,去查看代码当中每一步执行的结果,这样就能对注解、反射、动态代理有一个更具体的认识。
前面带你看过的这个动图,其实就是在向你展示代码在调试过程中的关键节点,我们可以再来回顾一下整个代码的执行流程:
相信现在,你已经能够体会我们使用 动态代理+注解+反射 实现这个网络请求框架的原因了。通过这样的方式,我们就不必在代码当中去实现每一个接口,而是只要是符合这样的代码模式,任意的接口和方法,我们都可以直接传进去。在这个例子当中,我们用的是ApiService这个接口,如果下次我们定义了另一个接口,比如说:
interface GitHubService {
@GET("/search")
fun search(
@Field("id") id: String
): User
}
这时候,我们的KtHttp根本不需要做任何的改动,直接这样调用即可:
fun main() {
KtHttpV1.baseUrl = "https://api.github.com"
// 换一个接口名即可 换一个接口名即可
// ↓ ↓
val api: GitHubService = KtHttpV1.create(GitHubService::class.java)
val data: User = api.search(id = "JetBrains")
}
可以发现,使用动态代理实现网络请求的优势,它的灵活性是非常好的。只要我们定义的Service接口拥有对应的注解GET、Field,我们就可以通过注解与反射,将这些信息拼凑在一起。下面这个动图就展示了它们整体的流程:
实际上,我们的KtHttp,就是将URL的信息存储在了注解当中(比如lang和since),而实际的参数值,是在函数调用的时候传进来的(比如Kotlin和weekly)。我们通过泛型、注解、反射的结合,将这些信息集到一起,完成整个URL的拼接,最后才通过OkHttp完成的网络请求、Gson完成的解析。
好,到这里,我们1.0版本的开发就算是完成了。这里的单元测试代码很容易写,我就不贴出来了,单元测试是个好习惯,我们不能忘。
接下来,我们正式进入2.0版本的开发。
其实,如果你理解了1.0版本的代码,2.0版本的程序也就不难实现了。因为这个程序的主要功能都已经完成了,现在要做的只是:换一种思路重构代码。
我们先来看看KtHttpV1这个单例的成员变量:
object KtHttpV1 {
private var okHttpClient: OkHttpClient = OkHttpClient()
private var gson: Gson = Gson()
fun <T> create(service: Class<T>): T {}
fun invoke(url: String, method: Method, args: Array<Any>): Any? {}
}
okHttpClient、gson这两个成员是不支持懒加载的,因此我们首先应该让它们支持懒加载。
object KtHttpV2 {
private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }
fun <T> create(service: Class<T>): T {}
fun invoke(url: String, method: Method, args: Array<Any>): Any? {}
}
这里,我们直接使用了by lazy委托的方式,它简洁的语法可以让我们快速实现懒加载。
接下来,我们再来看看create()这个方法的定义:
// 注意这里
// ↓
fun <T> create(service: Class<T>): T {
return Proxy.newProxyInstance(
service.classLoader,
arrayOf<Class<*>>(service)
) { proxy, method, args ->
}
}
在上面的代码中,create()会接收一个Class<T>
类型的参数。其实,针对这样的情况,我们完全可以省略掉这个参数。具体做法,是使用我们前面学过的inline,来实现类型实化(Reified Type)。我们常说,Java的泛型是伪泛型,而这里我们要实现的就是真泛型。
// 注意这两个关键字
// ↓ ↓
inline fun <reified T> create(): T {
return Proxy.newProxyInstance(
T::class.java.classLoader, // ① 变化在这里
arrayOf(T::class.java) // ② 变化在这里
) { proxy, method, args ->
// 待重构
}
}
正常情况下,泛型参数类型会被擦除,这就是Java的泛型被称为“伪泛型”的原因。而通过使用inline和reified这两个关键字,我们就能实现类型实化,也就是“真泛型”,进一步,我们就可以在代码注释①、②的地方,使用“T::class.java”来得到Class对象。
下面,我们来看看KtHttp的主要逻辑该如何重构。
为了方便理解,我们会使用Kotlin标准库当中已有的高阶函数,尽量不去涉及函数式编程里的高级概念。在这里我强烈建议你打开IDE一边敲代码一边阅读,这样一来,当你遇到不熟悉的标准函数时,就可以随时去看它的实现源码了。相信在学习过第7讲的高阶函数以后,这些库函数都不会难倒你。
首先,我们来看看create()里面“待重构”的代码该如何写。在这个方法当中,我们需要读取method当中的GET注解,解析出它的值,然后与baseURL拼接。这里我们完全可以借助Kotlin的标准库函数来实现:
inline fun <reified T> create(): T {
return Proxy.newProxyInstance(
T::class.java.classLoader,
arrayOf(T::class.java)
) { proxy, method, args ->
return@newProxyInstance method.annotations
.filterIsInstance<GET>()
.takeIf { it.size == 1 }
?.let { invoke("$baseUrl${it[0].value}", method, args) }
} as T
}
这段代码的可读性很好,我们可以像读英语文本一样来阅读:
filterIsInstance<GET>()
,来筛选出我们想要找的GET注解。这里的filterIsInstance其实是filter的升级版,也就是过滤的意思;好了,create()方法的重构已经完成,接下来我们来看看invoke()方法该如何重构。
fun invoke(url: String, method: Method, args: Array<Any>): Any? =
method.parameterAnnotations
.takeIf { method.parameterAnnotations.size == args.size }
?.mapIndexed { index, it -> Pair(it, args[index]) }
?.fold(url, ::parseUrl)
?.let { Request.Builder().url(it).build() }
?.let { okHttpClient.newCall(it).execute().body?.string() }
?.let { gson.fromJson(it, method.genericReturnType) }
这段代码读起来也不难,我们一行一行来分析。
@Field("lang")
、@Field("since")
。@Field("lang")
、@Field("since")
的数量是2,那么["Kotlin", "weekly"]
的size也应该是2,它必须是一一对应的关系。@Field("lang")
与"Kotlin"
进行配对,将@Field("since")
与"weekly"
进行配对。这里的mapIndexed,其实就是map的升级版,它本质还是一种映射的语法,“注解数组类型”映射成了“Pair数组”,只是多了一个index而已。到目前为止,我们的invoke()方法的主要流程就分析完了,接下来我们再来看看用于实现URL拼接的parseUrl()是如何实现的。
private fun parseUrl(acc: String, pair: Pair<Array<Annotation>, Any>) =
pair.first.filterIsInstance<Field>()
.first()
.let { field ->
if (acc.contains("?")) {
"$acc&${field.value}=${pair.second}"
} else {
"$acc?${field.value}=${pair.second}"
}
}
可以看到,这里我们只是把从前的for循环代码,换成了 Kotlin的集合操作符而已。大致流程如下:
至此,我们2.0版本的代码就完成了,完整的代码如下:
object KtHttpV2 {
private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }
var baseUrl = "https://baseurl.com" // 可改成任意url
inline fun <reified T> create(): T {
return Proxy.newProxyInstance(
T::class.java.classLoader,
arrayOf(T::class.java)
) { proxy, method, args ->
return@newProxyInstance method.annotations
.filterIsInstance<GET>()
.takeIf { it.size == 1 }
?.let { invoke("$baseUrl${it[0].value}", method, args) }
} as T
}
fun invoke(url: String, method: Method, args: Array<Any>): Any? =
method.parameterAnnotations
.takeIf { method.parameterAnnotations.size == args.size }
?.mapIndexed { index, it -> Pair(it, args[index]) }
?.fold(url, ::parseUrl)
?.let { Request.Builder().url(it).build() }
?.let { okHttpClient.newCall(it).execute().body?.string() }
?.let { gson.fromJson(it, method.genericReturnType) }
private fun parseUrl(acc: String, pair: Pair<Array<Annotation>, Any>) =
pair.first.filterIsInstance<Field>()
.first()
.let { field ->
if (acc.contains("?")) {
"$acc&${field.value}=${pair.second}"
} else {
"$acc?${field.value}=${pair.second}"
}
}
}
对应的,我们可以再看看1.0版本的完整代码:
object KtHttpV1 {
private var okHttpClient: OkHttpClient = OkHttpClient()
private var gson: Gson = Gson()
var baseUrl = "https://baseurl.com" // 可改成任意url
fun <T> create(service: Class<T>): T {
return Proxy.newProxyInstance(
service.classLoader,
arrayOf<Class<*>>(service)
) { proxy, method, args ->
val annotations = method.annotations
for (annotation in annotations) {
if (annotation is GET) {
val url = baseUrl + annotation.value
return@newProxyInstance invoke(url, method, args!!)
}
}
return@newProxyInstance null
} as T
}
private fun invoke(path: String, method: Method, args: Array<Any>): Any? {
if (method.parameterAnnotations.size != args.size) return null
var url = path
val parameterAnnotations = method.parameterAnnotations
for (i in parameterAnnotations.indices) {
for (parameterAnnotation in parameterAnnotations[i]) {
if (parameterAnnotation is Field) {
val key = parameterAnnotation.value
val value = args[i].toString()
if (!url.contains("?")) {
url += "?$key=$value"
} else {
url += "&$key=$value"
}
}
}
}
val request = Request.Builder()
.url(url)
.build()
val response = okHttpClient.newCall(request).execute()
val genericReturnType = method.genericReturnType
val body = response.body
val json = body?.string()
val result = gson.fromJson<Any?>(json, genericReturnType)
return result
}
}
可见,1.0版本、2.0版本,它们之间可以说是天壤之别。
好了,这节实战就到这里。接下来我们来简单总结一下:
在前面的加餐课程当中,我们也讨论过Kotlin的编程范式问题。命令式还是函数式,这完全取决于我们开发者自身。
相比起前面实战课中的单词频率统计程序,这一次我们的函数式范式的代码,实现起来就没有那么得流畅了。原因其实也很简单,Kotlin提供了强大的集合操作符,这就让Kotlin十分擅长“集合操作”的场景,因此词频统计程序,我们不到10行代码就解决了。而对于注解、反射相关的场景,函数式的编程范式就没那么擅长了。
在这节课里,我之所以费尽心思地用函数式风格,重构出KtHttp 2.0版本,主要还是想让你看到函数式编程在它不那么擅长的领域表现会如何。毕竟,我们在工作中什么问题都可能会遇到。
好了,学完这节课以后,请问你有哪些感悟和收获?请在评论区里分享出来,我们一起交流吧!
评论