本文地址


12 | 实战:网络请求框架 KtHttp

在 Java 和 Kotlin 领域,有许多出色的网络请求框架,比如 OkHttpRetrofitFuel。Retrofit 的底层使用了大量的泛型、注解和反射的技术。

  • 1.0 版本,用 Java 命令式风格,实现同步式的 GET 网络请求
  • 2.0 版本,用函数式风格重构代码

Java 命令式风格

需要引入的依赖:

implementation "org.jetbrains.kotlin:kotlin-stdlib"
implementation "org.jetbrains.kotlin:kotlin-reflect"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.google.code.gson:gson:2.8.9'

数据类 RepoList 和 Repo

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?
)

网络请求接口 ApiService

interface ApiService {
    @GET("/repo")
    fun repos(@Field("lang") lang: String, @Field("since") since: String): RepoList
}

@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) // 请求参数

动态代理 Proxy

这里是利用了 Java 的动态代理来创建接口的实例。

Object newProxyInstance(ClassLoader l, Class<?>[] s, InvocationHandler h)

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
}

具体逻辑如下:

fun <T> create(service: Class<T>): T {
    val loader: ClassLoader = service.classLoader
    val interfaces = arrayOf<Class<*>>(service) // 对象 service 所有实现的接口
    val any: Any? = Proxy.newProxyInstance(loader, interfaces) { proxy, method, args ->
        // 调用 Proxy.newProxyInstance 就可以创建接口的实例化对象,且此方法符合 SAM 转换
        println("${service.simpleName} - ${proxy.javaClass.simpleName} " +
                "- ${method.name} - ${Arrays.toString(args)}")
        // ApiService - $Proxy0 - repos - [Kotlin, weekly]
        val annotations = method.annotations // 这里的 method 指的是 repos 方法
        for (annotation in annotations) {
            if (annotation is GET) { // 遍历所有的注解,找到 GET 注解
                val value = annotation.value // 获取 GET 注解中的值
                val url = "https://trendings.herokuapp.com$value" // 拼接完整的 URL
                return@newProxyInstance request(url, method, args) // 调用并返回
            }
        }
        return@newProxyInstance null // 返回 Lambda,而非 create() 函数
    }
    @Suppress("UNCHECKED_CAST")
    return any as T // 不能保证返回值类型,这里是将其强制转换成了 T 类型
}

Lambda 表达式的返回语法:上面代码中的 return@newProxyInstance 代表返回 Lambda,而直接的 return,代表了返回 create() 这个函数。

由于动态代理大量应用了反射,加之业务代码中还牵涉到泛型注解Lambda表达式,所以上面的代码可能没那么容易理解。

网络请求 OkHttp Gson

private fun request(path: String, method: Method, args: Array<Any>): Any? {
    // 这里的 method 指的是 repos 方法,args 指的是调用 repos 方法时传的参数
    val annotations = method.parameterAnnotations // 取出方法参数中的所有注解
    if (annotations.size != args.size) return null // 参数和参数注解的个数相同
    // 这两个值并不一定相等,因为有些参数可能不需要注解,有些参数可能有多个注解

    var url = path // 拼接参数
    for (indice in annotations.indices) {
        for (parameterAnnotation in annotations[indice]) {
            if (parameterAnnotation is Field) { // 筛选出 Field 注解
                val key = parameterAnnotation.value
                val value = args[indice].toString()
                val param = "$key=$value"
                url += if (url.contains("?")) "&$param" else "?$param"
            }
        }
    }

    val request = Request.Builder().url(url).build()  // 底层使用 OkHttp
    val response = OkHttpClient().newCall(request).execute() // 发起网络请求

    val type = method.genericReturnType // 方法的返回值类型
    val body: ResponseBody? = response.body
    val json = body?.string()
    return Gson().fromJson(json, type) // 使用 Gson 解析
}

使用与总结

通过这样的方式,我们就不必在代码当中去实现每一个接口,符合条件的任意接口和方法,都可以通过下面简单的两行代码获取接口数据。

fun main() {
    val api: ApiService = create(ApiService::class.java) // 通过动态代理创建接口的实例
    val data: RepoList = api.repos(lang = "Kotlin", since = "weekly") // 获取接口数据
    println("${data.count} - ${data.msg} \n${Gson().toJson(data.items)}")
}

如果定义了另一个接口,这时候,我们的主体逻辑不需要做任何的改动,直接像下面这样调用即可:

interface GitHubService {
    @GET("/search")
    fun search(@Field("id") id: String): User
}

val api: GitHubService = create(GitHubService::class.java) // 换成另外一个接口
val data: User = api.search(id = "JetBrains")  //换成另外一个方法,传相应的参数

可以发现,使用动态代理实现网络请求的灵活性非常好。只要我们定义的 Service 接口拥有对应的注解,我们就可以通过注解与反射,将这些信息拼凑在一起。

实际上,我们的 KtHttp,就是将 URL 的信息存储在了注解当中(比如 lang 和 since),而实际的参数值,是在函数调用的时候传进来的(比如 Kotlin 和 weekly)。我们通过泛型、注解、反射的结合,将这些信息集到一起,完成整个 URL 的拼接,最后才通过 OkHttp 完成的网络请求。

函数式风格重构

by lazy 懒加载委托

private var okHttpClient: OkHttpClient = OkHttpClient()
private var gson: Gson = Gson()

// 支持懒加载
private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }

类型实化 -- 真泛型

在下面的代码中,create() 会接收一个 Class<T> 类型的参数。其实,针对这样的情况,我们完全可以省略掉这个参数。

fun <T> create(service: Class<T>): T {
    return Proxy.newProxyInstance(
        service.classLoader,
        arrayOf<Class<*>>(service)
    ) { ... }
}

正常情况下,泛型参数类型会被擦除,这就是 Java 的泛型被称为伪泛型的原因。而通过使用 inlinereified 这两个关键字,我们就能实现 类型实化(Reified Type),也就是真泛型。进一步,我们就可以在下面代码注释 ①、② 的地方,使用 T::class.java 来得到 Class 对象。

inline fun <reified T> create(): T { // 省略参数 Class<T>,添加关键字 inline 和 reified
    return Proxy.newProxyInstance(
        T::class.java.classLoader,   // 使用 T::class.java 来得到 Class 对象
        arrayOf(T::class.java)       // 使用 T::class.java 来得到 Class 对象
    ) { ... }
}

使用标准库函数优化 create

在 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 // 获取 method 的所有注解
            .filterIsInstance<GET>() // 筛选出 GET 注解
            .takeIf { it.size == 1 } // 筛选出 GET 注解的数量是 1 的注解
            ?.let { request("$baseUrl${it[0].value}", method, args) } // 判空后请求
    } as T
}

使用标准库函数优化 request

下面同样借助 Kotlin 的标准库函数,对 request() 方法进行重构。

fun request(url: String, method: Method, args: Array<Any>): Any? = method
   .parameterAnnotations // 获取方法当中所有的参数注解
   .takeIf { it.size == args.size } // 判断数量是否相等
   ?.mapIndexed { index, it -> Pair(it, args[index]) } // 映射配对
   ?.fold(url, ::parseUrl) // 函数引用,fold 是高阶函数版的 for 循环
   ?.let { Request.Builder().url(it).build() } // 构建 Request 对象
   ?.let { okHttpClient.newCall(it).execute().body?.string() } // 网络请求
   ?.let { gson.fromJson(it, method.genericReturnType) } // Gson 解析

上面的代码中,我们引用了一个实现 URL 拼接的 parseUrl() 函数:

private fun parseUrl(acc: String, pair: Pair<Array<Annotation>, Any>) =
    pair.first
        .filterIsInstance<Field>() // 筛选出 Field 类型的注解
        .first() // 取出第一个 Field 注解,这里它也应该是唯一的
        .let { field -> // 拼接参数
            val param = "${field.value}=${pair.second}"
            if (acc.contains("?")) "$acc&$param" else "$acc?$param"
        }

至此,我们 2.0 版本的代码就完成了。

小结

  • 在 1.0 版本的代码中,我们灵活利用了 动态代理、泛型、注解、反射 这几个技术,实现了 KtHttp 的基础功能
  • 动态代理:我们通过 ApiImpl 这个类,模拟了它动态生成的 Proxy 类
  • 泛型:我们将其用在了动态代理的 create() 方法上,后面我们还使用了类型实化的技术
  • 注解:我们自定义了两个注解
  • 反射:我们通过反射分析了 repos() 方法
    • 从方法注解中取出了注解的值,用于拼接网络请求的地址
    • 还取出了方法的返回值类型,用于 Gson 数据解析
  • 在 2.0 版本的代码中,我们以函数式的思维重写了 KtHttp 的内部逻辑,并且大量使用了 Kotlin 标准库里的高阶函数,进一步提升了代码的可读性

相比起前面实战课,这一次我们的函数式范式的代码,实现起来没有那么得流畅。因为 Kotlin 提供了强大的集合操作符,这就让 Kotlin 十分擅长集合操作的场景。而对于注解、反射相关的场景,函数式的编程范式就没那么擅长了。

2016-07-29

相关文章:

  • 2021-08-26
  • 2021-06-22
  • 2022-02-04
  • 2021-06-30
  • 2021-07-07
  • 2021-10-18
  • 2022-12-23
  • 2021-06-28
猜你喜欢
  • 2022-12-23
  • 2021-11-20
  • 2021-08-10
  • 2021-11-28
  • 2021-05-13
  • 2022-01-28
  • 2022-12-23
相关资源
相似解决方案