Kotlin网络库Fuel的设计之道

使用场景

一个“朴素”的 url 完全可以用一个字符串来表示(例如 "https://www.youzan.com"),我们可以利用 Kotlin 语言本身的特性为 String 类型添加一个扩展函数 httpGet(),然后借此发起 http 请求:

"https://www.youzan.com".httpGet()

但是,对于不是朴素字符串的对象来说,我们可以让其实现一个接口:

interface PathStringConvertible {
    val path: String
}

然后,将“计算”过后的 path 通过一个 String 类型提供出来,例如:

enum class HttpsBin(relativePath: String) : Fuel.PathStringConvertible {
    USER_AGENT("user-agent"),
    POST("post"),
    PUT("put"),
    PATCH("patch"),
    DELETE("delete");

    override val path = "https://httpbin.org/$relativePath"
}

但是,也会存在一种情况,所有的 url 可能会共享一个 base url,或者是其他公用参数,那么还需有一个地方来存储这些通用配置,这个地方的幕后老大就叫 FuelManager

StringPathStringConvertible 最终也会调用到 FuelManager

+----------+
|  String  |------------->----+
+----------+                  |    +------+    +-------------+
                              |--->| Fuel |--->| FuelManager |
+-------------------------+   |    +------+    +-------------+
|  PathStringConvertible  |->-+
+-------------------------+

除了通过 String 或者 PathStringConvertiable 来发起请求,我们还可以直接用一个 Request,因此 Fuel 还提供了转换 Request 的接口:
除了通过 String 或者 PathStringConvertiable 来发起请求,我们还可以直接用一个 Request,因此 Fuel 还提供了转换 Request 的接口:

interface RequestConvertible {
    val request: Request
}

综上来看,发起一个 http 请求可以有如下四种方式:

  1. 一个字符串
  2. PathStringConvertible 变量
  3. RequestConvertible 变量
  4. 直接使用 Fuel 伴生对象提供的方法

代码实现

对外提供服务的 Fuel

首先 Fuel 作为对外的接口提供方(类似 Facade 模式),通过一个伴生对象(companion object)提供服务(以 get 方法为例):

companion object {
  @JvmStatic @JvmOverloads
  fun get(path: String, parameters: List<Pair<String, Any?>>? = null): Request =
          request(Method.GET, path, parameters)

  @JvmStatic @JvmOverloads
  fun get(convertible: PathStringConvertible, parameters: List<Pair<String, Any?>>? = null): Request =
          request(Method.GET, convertible, parameters)

  private fun request(method: Method, path: String, parameters: List<Pair<String, Any?>>? = null): Request =
          FuelManager.instance.request(method, path, parameters)

  private fun request(method: Method, convertible: PathStringConvertible, parameters: List<Pair<String, Any?>>? = null): Request =
          request(method, convertible.path, parameters)
}

Fuel 类通过伴生对象提供的 http 方法有 get/post/put/patch/delete/download/upload/head,这些方法最终会路由到 FuleManager 的实例(instance)。

同时,Fule.kt 源文件为 StringPathStringConvertible 定义了扩展,以支持这些 http 方法(以 get 方法为例):

@JvmOverloads
fun String.httpGet(parameters: List<Pair<String, Any?>>? = null): Request = Fuel.get(this, parameters)

@JvmOverloads
fun Fuel.PathStringConvertible.httpGet(parameter: List<Pair<String, Any?>>? = null): Request = Fuel.get(this, parameter)

幕后老大 FuleManager

FuleManager 利用伴生对象实现了单例模式:

companion object {
  //manager
  var instance by readWriteLazy { FuelManager() }
}

同时利用代理属性实现了单例的懒加载。

readWriteLazy 是一个函数,它的返回值是一个 ReadWriteProperty,代码比较容易,具体可见 Delegates.kt

也就是说,当我们第一次访问 FuelManager 时,一个具体的实例会被创建出来,这个实例担负了存储公用配置和发起请求的重任,首先来看它的属性:

var client: Client
var proxy: Proxy?
var basePath: String?

var baseHeaders: Map<String, String>?
var baseParams: List<Pair<String, Any?>>

var keystore: KeyStore?
var socketFactory: SSLSocketFactory

var hostnameVerifier: HostnameVerifier

Client 是一个接口,通过它我们可以自定义 http 引擎。

interface Client {
  fun executeRequest(request: Request): Response
}
+---------+     +--------+     +----------+
| Request | ==> | Client | ==> | Response |
+---------+     +--------+     +----------+
                     |
                    \|/                   +--------------------+
              +------------+              | HttpURLConnection  |
              | HttpClient | --based on-- +--------------------+
              +------------+              | HttpsURLConnection |
                                          +--------------------+

Fuel 默认提供的 Http 引擎是 HttpClient,它是基于 HttpURLConnection 的实现。
Fuel 默认提供的 Http 引擎是 HttpClient,它是基于 HttpURLConnection 的实现。

basePathbaseHeadersbaseParams 存储了请求的公用配置,我们可以通过 FuleManager.instance 为其赋值:

FuelManager.instance.apply {
  basePath = "http://httpbin.org"
  baseHeaders = mapOf("Device" to "Android")
  baseParams = listOf("key" to "value")
}

keystore 用于构建 socketFactory,再加上 hostnameVerifier,它们用于 https 请求,在 HttpClient 中有用到:

private fun establishConnection(request: Request): URLConnection {
  val urlConnection = if (proxy != null) request.url.openConnection(proxy) else request.url.openConnection()
  return if (request.url.protocol == "https") {
    val conn = urlConnection as HttpsURLConnection
    conn.apply {
      sslSocketFactory = request.socketFactory // socketFactory
      hostnameVerifier = request.hostnameVerifier // hostnameVerifier
    }
  } else {
    urlConnection as HttpURLConnection
  }
}

如果要深入了解 HTTPS 证书,可参考 「HTTPS 精读之 TLS 证书校验」。

FuelManager 在发起请求时会用这些参数构建一个 Request

fun request(method: Method, path: String, param: List<Pair<String, Any?>>? = null): Request {
  val request = request(Encoding(
        httpMethod = method,
        urlString = path,
        baseUrlString = basePath,
        parameters = if (param == null) baseParams else baseParams + param
  ).request)

  request.client = client
  request.headers += baseHeaders.orEmpty()
  request.socketFactory = socketFactory
  request.hostnameVerifier = hostnameVerifier
  request.executor = createExecutor()
  request.callbackExecutor = callbackExecutor
  request.requestInterceptor = requestInterceptors.foldRight({ r: Request -> r }) { f, acc -> f(acc) }
  request.responseInterceptor = responseInterceptors.foldRight({ _: Request, res: Response -> res }) { f, acc -> f(acc) }
  return request
}

关于 requestInterceptorresponseInterceptor,原理与 OkHttp 实现的拦截器一致,只不过这里利用了 Kotlin 的高阶函数,代码实现非常简单,具体细节可参考 「Kotlin实战之Fuel的高阶函数」。

跟其他网络库一样,一次完整的请求,必然包含两个实体—— Request & Response,先来看 Request

请求实体 Request

class Request(
  val method: Method,
  val path: String,
  val url: URL,
  var type: Type = Type.REQUEST,
  val headers: MutableMap<String, String> = mutableMapOf(),
  val parameters: List<Pair<String, Any?>> = listOf(),
  var name: String = "",
  val names: MutableList<String> = mutableListOf(),
  val mediaTypes: MutableList<String> = mutableListOf(),
  var timeoutInMillisecond: Int = 15000,
  var timeoutReadInMillisecond: Int = timeoutInMillisecond) : Fuel.RequestConvertible

它支持三种类型的请求:

enum class Type {
  REQUEST,
  DOWNLOAD,
  UPLOAD
}

针对每个类型都有对应的任务(task):

//underlying task request
internal val taskRequest: TaskRequest by lazy {
  when (type) {
    Type.DOWNLOAD -> DownloadTaskRequest(this)
    Type.UPLOAD -> UploadTaskRequest(this)
    else -> TaskRequest(this)
  }
}

涉及到上传下载的 DownloadTaskRequestUploadTaskRequest 都继承自 TaskRequest,它们会处理文件和流相关的东西,关于此可参考 IO 哥写的 一些「流与管道」的小事 以及 OK, IO

FuelManager 在构造 Request 时用到了一个类——Encoding

class Encoding(
  val httpMethod: Method,
  val urlString: String,
  val requestType: Request.Type = Request.Type.REQUEST,
  val baseUrlString: String? = null,
  val parameters: List<Pair<String, Any?>>? = null) : Fuel.RequestConvertible

Encoding 也是继承自 Fuel.RequestConvertible,它完成了对 Request 参数的组装编码,并产生了一个 Request

Encoding 组装 query parameter 的方式可以说赏心悦目,贴出来欣赏一下:

private fun queryFromParameters(params: List<Pair<String, Any?>>?): String = params.orEmpty()
  .filterNot { it.second == null }
  .map { (key, value) ->  URLEncoder.encode(key, "UTF-8") to URLEncoder.encode("$value", "UTF-8") }
  .joinToString("&") { (key, value) -> "$key=$value" }

请求返回结果 Response

class Response(
  val url: URL,
  val statusCode: Int = -1,
  val responseMessage: String = "",
  val headers: Map<String, List<String>> = emptyMap(),
  val contentLength: Long = 0L,
  val dataStream: InputStream = ByteArrayInputStream(ByteArray(0))

Response 的属性可以看出,它所携带的仍然是一个流(Stream),我们先看 Response 是如何与 Request 串联起来的。

Deserializable.kt 文件为 Request 定了名称为 response 的扩展函数:

private fun <T : Any, U : Deserializable<T>> Request.response(
  deserializable: U,
  success: (Request, Response, T) -> Unit,
  failure: (Request, Response, FuelError) -> Unit): Request {

    val asyncRequest = AsyncTaskRequest(taskRequest)

    asyncRequest.successCallback = { response ->
      val deliverable = Result.of { deserializable.deserialize(response) }
      callback {
        deliverable.fold({
          success(this, response, it)
        }, {
          failure(this, response, FuelError(it))
        })
      }
    }

    asyncRequest.failureCallback = { error, response ->
      callback {
        failure(this, response, error)
      }
    }

    submit(asyncRequest)
    return this
}

扩展函数 response 的参数中,deserializable 负责反序列化操作,successfailure 用于处理请求结果。

Fuel 提供了两个 Deserializable 的实现:StringDeserializer 以及 ByteArrayDeserializer,它们用于反序列化 response 的 stream。

异步请求

Deserializable.ktRequest 定义的扩展函数 response 在执行异步操作时用到了一个 AsnycTaskRequest,其实它本身并不提供异步实现,而是交由一个 ExecutorService 去执行,而这个 ExecutorService 恰由 FuelManager 定义,并在构造 Request 时传入给它。

FuleManager.kt

//background executor
var executor: ExecutorService by readWriteLazy {
  Executors.newCachedThreadPool { command ->
    Thread(command).also { thread ->
      thread.priority = Thread.NORM_PRIORITY
      thread.isDaemon = true
    }
  }
}

AsyncTaskRequestUploadTaskRequestDownloadTaskRequest 一样,都是继承自 TaskRequest,只不过它多了两个异步调用的回调:

var successCallback: ((Response) -> Unit)? = null
var failureCallback: ((FuelError, Response) -> Unit)? = null

请求图例

至此,请求、回复,异步调用,对外接口都了解过了,一个基本的网络库框架已经成型。

         +------------------------+
         | https://www.youzan.com |
         +------------------------+
                     |
                     |
                    \|/
                  +------+
                  | Fuel |
                  +------+
                     |
                     |
                    \|/
              +-------------+
              | FuelManager |
              +-------------+
                     |
                     |
                    \|/
+---------+      +--------+      +----------+
| Request | ===> | Client | ===> | Response |
+---------+      +--------+      +----------+

虽然Fuel 的复杂度不可与 OkHttp 相提并论,但是依赖 Kotlin 语言本身的灵活性,它的代码却比 OkHttp 要简洁的多,特别是关于高阶函数和扩展函数的运用,极大地提升了代码的可读性。

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容