Android开源框架系列-OkHttp4.11.0(kotlin版)- 拦截器分析之CacheInterceptor

前言

CacheInterceptor主要处理http请求过程中的缓存逻辑,默认只支持GET类型的请求,当第一次请求时,由于本地没有缓存,会先请求服务器,拿到响应后再将返回内容缓存到本地,再次请求时,满足条件情况下直接使用缓存,减少网络请求的发送,降低对流量的消耗。使okhttp支持缓存,我们首先需要主动的开启缓存配置。

java 复制代码
OkHttpClient client = new OkHttpClient().newBuilder()
        .cache(new Cache(new File(getCacheDir(),"okhttp_cache"),10000))
        .build();

intercept

同样,直接进入拦截器的intercept方法,看看这个缓存拦截器到底是如何实现的。缓存的逻辑是先存再取,所以我们不按照代码顺序来看了,直接按照使用的顺序,先看看缓存是怎么存下来的,从拿到服务端响应开始入手。

缓存写入

networkResponse就是从服务端拿到的数据,用它构建一个新的response对象,并将其成员cacheResponse和networkResponse的body都置空。

scss 复制代码
val response = networkResponse!!.newBuilder()
    .cacheResponse(stripBody(cacheResponse))
    .networkResponse(stripBody(networkResponse))
    .build()

接来下开始进入缓存操作逻辑。这里的cache就是我们给OkHttpClient对象设置的那个cache对象,

scss 复制代码
if (cache != null) {
  //见1.判断是否有响应内容返回;并且是否可以缓存,见2
  if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {
    // 满足缓存的条件,则准备开始缓存,见4
    val cacheRequest = cache.put(response)
    //cacheRequest不为null就说明要成功了,具体写入缓存的细节就不分析了。
    return cacheWritingResponse(cacheRequest, response).also {
      if (cacheResponse != null) {
        listener.cacheMiss(call)
      }
    }
  }

1.promisesBody

方法用于判断响应是否有内容。

kotlin 复制代码
fun Response.promisesBody(): Boolean {
  // 不管响应头是什么,HEAD请求都不会返回内容。
  if (request.method == "HEAD") {
    return false
  }
  //http状态码204(无内容)服务器成功处理了请求,但没有返回任何内容。
  //http状态码304 自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。
  //所以这两种情况下没有响应正文返回
  val responseCode = code
  if ((responseCode < HTTP_CONTINUE || responseCode >= 200) &&
      responseCode != HTTP_NO_CONTENT &&
      responseCode != HTTP_NOT_MODIFIED) {
    return true
  }

  //走到这里说明code值有问题,但是响应头却提示有内容返回,说明二者之一总有一个是错的
  //但是为了最大的兼容性,则尊重响应头的提示,认为有内容返回。
  //Content-Length值不为-1表示有内容,Transfer-Encoding表示分块响应,也是有内容的
  if (headersContentLength() != -1L ||
      "chunked".equals(header("Transfer-Encoding"), ignoreCase = true)) {
    return true
  }
  return false
}

2.isCacheable

响应是否可以缓存取决于它的响应码和响应头。

kotlin 复制代码
fun isCacheable(response: Response, request: Request): Boolean {
  when (response.code) {
    HTTP_OK,
    HTTP_NOT_AUTHORITATIVE,
    HTTP_NO_CONTENT,
    HTTP_MULT_CHOICE,
    HTTP_MOVED_PERM,
    HTTP_NOT_FOUND,
    HTTP_BAD_METHOD,
    HTTP_GONE,
    HTTP_REQ_TOO_LONG,
    HTTP_NOT_IMPLEMENTED,
    StatusLine.HTTP_PERM_REDIRECT -> {
      //这些code下响应都是可以被缓存的
    }

    HTTP_MOVED_TEMP,
    StatusLine.HTTP_TEMP_REDIRECT -> {
      // 当响应码为302或307时,见3.
      if (response.header("Expires") == null &&
          response.cacheControl.maxAgeSeconds == -1 &&
          !response.cacheControl.isPublic &&
          !response.cacheControl.isPrivate) {
        return false
      }
    }

    else -> {
      // 其他code都不能被缓存
      return false
    }
  }
  //最后,请求和响应头中都没有设置Cache-control:no-store,才能缓存,否则不行
  return !response.cacheControl.noStore && !request.cacheControl.noStore
}

3.临时重定向响应302、307

当响应码为302或307时,表示资源被临时重定向到其他地址了。这里提到了几个响应头,我们来了解下这几个头的含义。

vbnet 复制代码
Expires:表示资源过期的时间,格式如:Expires: Sat, 18 Nov 2028 06:17:41 GMT
maxAgeSeconds:表示的是响应头cache-control中的方法:max-age,如"cache-control: 'max-age=43200'",定义了资源可以在客户端缓存多久,单位是秒。43200秒等于12小时
isPublic:表明该资源可以被任何用户缓存,比如客户端,代理服务器等都可以缓存资源。
isPrivate:表明该资源只能被单个用户缓存,当指定private指令后,响应只以特定的用户作为对象。

所以从okhttp对这几个响应头的判断方式来看,针对这种被临时重定向的资源,响应头必须至少包含上边四个取值之一,才能被缓存,否则无法缓存。

4.put

put方法就是准备将响应内容写入缓存了,不过方法内部仍有一些判断,不满足还是会被退回。

kotlin 复制代码
internal fun put(response: Response): CacheRequest? {
  val requestMethod = response.request.method
  //判断若请求方法是POST、PATCH、PUT、DELETE、MOVE时,废弃掉此缓存,从缓存移除,见5
  if (HttpMethod.invalidatesCache(response.request.method)) {
    try {
      remove(response.request)
    } catch (_: IOException) {
      // The cache cannot be written.
    }
    return null
  }

  if (requestMethod != "GET") {
    // 不要缓存非GET响应。从技术上讲,可以缓存HEAD请求和一些POST请求,但这样做的复杂性很高,收益很低。
    return null
  }
  //看看响应头中有没有Vary,并且Vary的值是不是Vary: *,如果是,则不能缓存。见6
  //Vary: *的含义可以理解为,就是告诉缓存服务器(okhttp也算是缓存服务器),不要缓存这个内容
  //必须请求源服务器
  if (response.hasVaryAll()) {
    return null
  }
  //走到这里一定是满足缓存的条件的,构建一个Entry交给DiskLruCache去写入缓存
  val entry = Entry(response)
  var editor: DiskLruCache.Editor? = null
  try {
    editor = cache.edit(key(response.request.url)) ?: return null
    entry.writeTo(editor)
    return RealCacheRequest(editor)
  } catch (_: IOException) {
    abortQuietly(editor)
    return null
  }
}

5.invalidatesCache

当请求方法是POST、PATCH、PUT、DELETE、MOVE时,不支持缓存。

ini 复制代码
fun invalidatesCache(method: String): Boolean = (method == "POST" ||
    method == "PATCH" ||
    method == "PUT" ||
    method == "DELETE" ||
    method == "MOVE") // WebDAV

6.Vary头

Vary头是和缓存相关的一个响应头,如Vary: Accept-Encoding。在 HTTP 缓存的过程中,缓存是按照 URL 作为唯一标识来进行缓存的,也就是说,如果两个请求的 URL 是一致的,那么就会使用缓存,不会重新向服务器请求一份新的数据。

但是,如果两个相同URL的请求使用了不同的请求头,那么此时使用相同的缓存将会出现问题。因为缓存是按照 URL 来判断缓存的,如果两个请求的请求头不同,就会导致两个请求产生不同的响应结果,缓存就无法共享。这时候,Vary 头就可以解决这个问题。

Vary 头部的作用就是在响应头中返回一个 Vary 字段,其中包含一组可能影响该响应结果的请求头名称,如"Vary: Accept-Encoding"。当客户端发起一个新的请求,比如使用不同的 Accept-Encoding 头,那么服务器就可以根据 Vary 头部的信息来判断该响应是否可以被缓存。

缓存读取

前边我们分析了缓存写入的逻辑,缓存写入之后,下次请求该url地址,就会先去读取该地址下保存的缓存,并验证是否满足使用的条件。

ini 复制代码
val cacheCandidate = cache?.get(chain.request())
kotlin 复制代码
internal fun get(request: Request): Response? {
  //url转md5
  val key = key(request.url)
  //从DiskLruCache中读取缓存,为空则直接返回,第一次请求时为空
  val snapshot: DiskLruCache.Snapshot = try {
    cache[key] ?: return null
  } catch (_: IOException) {
    return null // Give up because the cache cannot be read.
  }
  //缓存不为空,构建一个Entry
  val entry: Entry = try {
    Entry(snapshot.getSource(ENTRY_METADATA))
  } catch (_: IOException) {
    snapshot.closeQuietly()
    return null
  }
  //从entry中读取到response返回
  val response = entry.response(snapshot)
  if (!entry.matches(request, response)) {
    response.body?.closeQuietly()
    return null
  }

  return response
}

继续往后看拿到缓存之后做了什么?

ini 复制代码
//计算得到一个strategy,strategy对象中包含一个networkRequest和cacheResponse
//最终将根据这两个值来决定是使用缓存还是再次请求网络服务器。
//我们来看下compute方法,见7
val strategy = CacheStrategy.Factory(now, chain.request(), cacheCandidate).compute()
val networkRequest = strategy.networkRequest
val cacheResponse = strategy.cacheResponse

上边得到了CacheStrategy之后,就开始根据CacheStrategy的networkRequest和cacheResponse两个成员来判断究竟是直接使用缓,还是直接请求网络,或者请求网络询问服务器是否使用缓存。

csharp 复制代码
/如果本地有缓存,但是缓存策略返回的cacheResponse说明缓存不可用,关闭这个cacheCandidate
if (cacheCandidate != null && cacheResponse == null) {
  cacheCandidate.body?.closeQuietly()
}

networkRequest、cacheResponse两个都为空,表示缓存不可用,但是又要强制使用缓存,只能返回一个空的结果。

erlang 复制代码
if (networkRequest == null && cacheResponse == null) {
  return Response.Builder()
      .request(chain.request())
      .protocol(Protocol.HTTP_1_1)
      .code(HTTP_GATEWAY_TIMEOUT)
      .message("Unsatisfiable Request (only-if-cached)")
      .body(EMPTY_RESPONSE)
      .sentRequestAtMillis(-1L)
      .receivedResponseAtMillis(System.currentTimeMillis())
      .build().also {
        listener.satisfactionFailure(call, it)
      }
}

继续往下,此时networkRequest为空,但是cacheResponse不为空,于是返回缓存即可。

scss 复制代码
if (networkRequest == null) {
  return cacheResponse!!.newBuilder()
      .cacheResponse(stripBody(cacheResponse))
      .build().also {
        listener.cacheHit(call, it)
      }
}

经过上边的一次判断,可以知道此时networkRequest不为空,cacheResponse可能为空也可能不为空。networkRequest不为空说明,需要询问服务器,那么就要问下服务器究竟能不能使用缓存了。

csharp 复制代码
if (cacheResponse != null) {
  listener.cacheConditionalHit(call, cacheResponse)
} else if (cache != null) {
  listener.cacheMiss(call)
}

var networkResponse: Response? = null
try {
  //进入下一个拦截器,开始请求服务器
  networkResponse = chain.proceed(networkRequest)
} finally {
  if (networkResponse == null && cacheCandidate != null) {
    cacheCandidate.body?.closeQuietly()
  }
}

服务器返回结果,304表示本地缓存和服务端的数据一致,没有发生过改变,那么此时是可以直接使用这个缓存的。

scss 复制代码
if (cacheResponse != null) {
  if (networkResponse?.code == HTTP_NOT_MODIFIED) {
    val response = cacheResponse.newBuilder()
        .headers(combine(cacheResponse.headers, networkResponse.headers))
        .sentRequestAtMillis(networkResponse.sentRequestAtMillis)
        .receivedResponseAtMillis(networkResponse.receivedResponseAtMillis)
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build()

    networkResponse.body!!.close()

    // header发生改变了(combine方法合并的header),更新缓存
    cache!!.trackConditionalCacheHit()
    cache.update(cacheResponse, response)
    //返回缓存
    return response.also {
      listener.cacheHit(call, it)
    }
  } else {
    cacheResponse.body?.closeQuietly()
  }
}

最后,走到这里说明cacheResponse为空,需要直接请求服务器拿到结果。

scss 复制代码
val response = networkResponse!!.newBuilder()
    .cacheResponse(stripBody(cacheResponse))
    .networkResponse(stripBody(networkResponse))
    .build()
//更新缓存的逻辑省略
return response

代码逻辑走到这里,缓存拦截器的主流程逻辑我们就分析完了。

7.compute

kotlin 复制代码
fun compute(): CacheStrategy {
  //见8
  val candidate = computeCandidate()

  // 虽然客观条件不让使用缓存,但是调用方强制设置必须使用缓存,那么只能设置两个null,响应失败
  if (candidate.networkRequest != null && request.cacheControl.onlyIfCached) {
    return CacheStrategy(null, null)
  }

  return candidate
}

8.computeCandidate

根据读到的缓存的具体情况来判断缓存是否可用,然后返回一个CacheStrategy对象,CacheStrategy中包含两个成员,一个是netWorkRequest,一个是cacheResponse,假如netWorkRequest非空,cacheResponse为空,则表示要发起网络请求;假如netWorkRequest为空,cacheResponse非空,则表示缓存可用。

kotlin 复制代码
private fun computeCandidate(): CacheStrategy {
  // 如果本地就没有缓存,那么直接将CacheStrategy中缓存cacheResponse设置为null,
  // 表示要请求网络
  if (cacheResponse == null) {
    return CacheStrategy(request, null)
  }

  // 对于https请求,如果其握手信息为空,则缓存也不能使用,握手信息包含加密方式,tls版本信息等等
  if (request.isHttps && cacheResponse.handshake == null) {
    return CacheStrategy(request, null)
  }

  // 根据响应码决定是否满足缓存的条件,这个方法我们在缓存写入的分析中提到过
  // 缓存写入前也会调用此方法判断响应能否缓存,不能则设置cache为null返回。见上边写入缓存中2
  if (!isCacheable(cacheResponse, request)) {
    return CacheStrategy(request, null)
  }
  //如果请求中cacheControl设置了noCache,这种情况肯定是不能使用缓存,所以cacheResponse设置为null
  //针对第二种情况,是判断请求头中,是否包含If-Modified-Since或If-None-Match,假如包含
  //则表示虽然本地有缓存,但是也要和服务器确认缓存是否可用,见9,
  val requestCaching = request.cacheControl
  if (requestCaching.noCache || hasConditions(request)) {
    return CacheStrategy(request, null)
  }
  //接下来开始对缓存中的cacheControl做判断了
  val responseCaching = cacheResponse.cacheControl
  //计算缓存的年龄,也就是缓存用了多久了,见10.
  val ageMillis = cacheResponseAge()
  //计算缓存的有效期,见11
  var freshMillis = computeFreshnessLifetime()

  //maxAgeSeconds != -1表示本次请求时,指定了缓存的有效时间
  //Cache-Control:max-age[秒] :资源最大有效时间
  if (requestCaching.maxAgeSeconds != -1) {
    //所以从指定的有效值和实际的有效值中取最小值作为有效时间
    freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))
  }
    
  //请求中有没有强制指定最小有效期时间,
  //min-fresh=[秒] :(请求)缓存最小新鲜度(用户认为这个缓存有效的时长)
  var minFreshMillis: Long = 0
  if (requestCaching.minFreshSeconds != -1) {
    minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds.toLong())
  }

  var maxStaleMillis: Long = 0
  //mustRevalidate(must-revalidate)(响应)不允许使用过期缓存
  //max-stale(请求)缓存过期后多久内仍然有效
  //这里表示调用方指定了不能使用过期缓存,所以要在缓存有效期内才能使用
  if (!responseCaching.mustRevalidate && requestCaching.maxStaleSeconds != -1) {
    maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds.toLong())
  }
  //如果没有指定不使用缓存,并且缓存还在指定的有效期内,则是满足使用缓存条件的
  if (!responseCaching.noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
    val builder = cacheResponse.newBuilder()
    //缩小时间范围判断下,虽然有效但是不新鲜了,通过响应头添加警告
    if (ageMillis + minFreshMillis >= freshMillis) {
      builder.addHeader("Warning", "110 HttpURLConnection "Response is stale"")
    }
    val oneDayMillis = 24 * 60 * 60 * 1000L
    //缓存响应超过24小时,需要附加警告。
    if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
      builder.addHeader("Warning", "113 HttpURLConnection "Heuristic expiration"")
    }
    //返回缓存
    return CacheStrategy(null, builder.build())
  }

  val conditionName: String
  val conditionValue: String?
  when {
    //缓存响应头中包含ETag标识,则请求中添加If-None-Match头,询问服务器资源是否存在
    etag != null -> {
      conditionName = "If-None-Match"
      conditionValue = etag
    }
    //缓存响应头中包含Last-Modified标识,则请求中添加If-Modified-Since头,询问服务器资源是否改变
    lastModified != null -> {
      conditionName = "If-Modified-Since"
      conditionValue = lastModifiedString
    }
    //缓存响应头中包含Date标识,请求中添加If-Modified-Since头,询问服务器资源是否改变
    servedDate != null -> {
      conditionName = "If-Modified-Since"
      conditionValue = servedDateString
    }
    //如果上边这些都没有,直接发送一个常规请求
    else -> return CacheStrategy(request, null) 
  }
  //将上边指定的header添加到request中
  val conditionalRequestHeaders = request.headers.newBuilder()
  conditionalRequestHeaders.addLenient(conditionName, conditionValue!!)

  val conditionalRequest = request.newBuilder()
      .headers(conditionalRequestHeaders.build())
      .build()
  //返回最终的缓存策略
  return CacheStrategy(conditionalRequest, cacheResponse)
}

9.hasConditions

kotlin 复制代码
private fun hasConditions(request: Request): Boolean =
    request.header("If-Modified-Since") != null || request.header("If-None-Match") != null
If-Modify-Since:

和Last-Modified关联使用,服务器下发一次资源,会同时下发资源的最后修改时间(Last-Modified),当客户端再次请求这个资源时,把上一次保存的最后修改时间带给服务器(通过请求头If-Modify-Since把Last-Modified带给服务器),服务器会把这个时间与服务器上实际文件的最后修改时间进行比较。如果时间一致,那么返回HTTP状态码304(不返回文件内容),客户端接到之后,就直接使用本地缓存文件;如果时间不一致,就返回HTTP状态码200和新的文件内容,客户端接到之后,会丢弃旧文件,把新文件缓存起来。

If-None-Match

和Etag关联使用,第一次发起http请求资源时,服务器会返回一个Etag(假设Etag:abcdefg1234567),在第二次发起同一个请求时,客户端在请求头同时发送一个If-None-Match,而它的值就是Etag的值(If-None-Match:abcdefg1234567),这个请求头需要客户端自己设置。然后服务器收到后会对比客户端发送过来的Etag是否与服务器的相同,如果相同,就将If-None-Match的值设为false,返回状态为304,客户端继续使用本地缓存;如果不相同,就将If-None-Match的值设为true,返回状态为200,客户端重新解析服务器返回的数据

10.cacheResponseAge

kotlin 复制代码
private fun cacheResponseAge(): Long {
  //servedDate指的就是当前缓存从服务器请求下来时,请求头中Date的时间值,
  //表示的是服务端返回这个资源时的时间。
  val servedDate = this.servedDate
  //receivedResponseMillis是客户端记录的请求被接收到的时间值
  //所以receivedResponseMillis - servedDate.time得到的是请求到这个资源时,
  //资源的寿命已经有多长了,如我断点得到的403,已经403毫秒了
  val apparentReceivedAge = if (servedDate != null) {
    maxOf(0, receivedResponseMillis - servedDate.time)
  } else {
    0
  }
  //响应头"Age"的值,如果没有这个头,则使用上边我们计算出的apparentReceivedAge赋值
  val receivedAge = if (ageSeconds != -1) {
    maxOf(apparentReceivedAge, SECONDS.toMillis(ageSeconds.toLong()))
  } else {
    apparentReceivedAge
  }
  //请求的时长减响应的时长,是请求到响应消耗的时长
  val responseDuration = receivedResponseMillis - sentRequestMillis
  //当前时间减去响应时间,得到的就是响应之后已经过了多久
  val residentDuration = nowMillis - receivedResponseMillis
  //所以最终缓存的年龄计算得到的就是这个资源被使用多久了
  return receivedAge + responseDuration + residentDuration
}

11.computeFreshnessLifetime

计算缓存的有效期

kotlin 复制代码
private fun computeFreshnessLifetime(): Long {
  val responseCaching = cacheResponse!!.cacheControl
  //缓存有没有指定有效期,如果maxAgeSeconds != -1表示指定了,那么返回这个时间
  if (responseCaching.maxAgeSeconds != -1) {
    return SECONDS.toMillis(responseCaching.maxAgeSeconds.toLong())
  }
  //缓存有没有指定过期时间,如果指定了,expires != null,
  val expires = this.expires
  if (expires != null) {
    //用过期时间减去接收到缓存的时间,得到的就是缓存在本地的有效期
    val servedMillis = servedDate?.time ?: receivedResponseMillis
    val delta = expires.time - servedMillis
    return if (delta > 0L) delta else 0L
  }
  //如果缓存响应头中包含了上次修改时间,并且请求这个缓存时的get请求中没有添加参数(query == null,见12)
  if (lastModified != null && cacheResponse.request.url.query == null) {
    // 用服务端响应的时间减去上次修改的时间,就是资源已经存在的时间
    val servedMillis = servedDate?.time ?: sentRequestMillis
    val delta = servedMillis - lastModified!!.time
    return if (delta > 0L) delta / 10 else 0L
  }

  return 0L
}

12.query指什么

okhttp中有明确的注释,若get请求中添加了请求参数,则query不为空,query就是参数部分。

ruby 复制代码
* | URL                               | `query()`              |
* | :-------------------------------- | :--------------------- |
* | `http://host/`                    | null                   |
* | `http://host/?`                   | `""`                   |
* | `http://host/?a=apple&k=key+lime` | `"a=apple&k=key lime"` |
* | `http://host/?a=apple&a=apricot`  | `"a=apple&a=apricot"`  |
* | `http://host/?a=apple&b`          | `"a=apple&b"`          |

总结

这篇文章缓存拦截器逻辑的分析耽误了很久,主要在写文章之前我希望先通过demo将流程完整的再验证一遍,虽然之前也分析过java版本的okhttp源码,但主要还是抱着浅尝则止的态度,一些问题没有深究,所以这次中间遇到了一些小问题导致迟迟无法往下推进,到今天总算完成了,研究或者分析问题,应该抱着剥洋葱的态度,一层一层,由外及里,才能有更深刻的认识,本文虽然相对于之前深入了一些细节,但还是有很多没有聊到的,因为篇幅有限,欢迎讨论。

相关推荐
2501_915909061 小时前
iOS App 安全性探索:源码保护、混淆方案与逆向防护日常
websocket·网络协议·tcp/ip·http·网络安全·https·udp
O。o.尊都假都2 小时前
socket套接字的超时控制
单片机·嵌入式硬件·网络协议
淘源码d2 小时前
什么是ERP?ERP有哪些功能?小微企业ERP系统源码,SpringBoot+Vue+ElementUI+UniAPP
java·源码·erp·erp源码·企业资源计划·企业erp·工厂erp
飞猿_SIR2 小时前
Android Exoplayer 实现多个音视频文件混合播放以及音轨切换
android·音视频
HumoChen993 小时前
GZip+Base64压缩字符串在ios上解压报错问题解决(安卓、PC模拟器正常)
android·小程序·uniapp·base64·gzip
christine-rr5 小时前
【25软考网工】第六章(4)VPN虚拟专用网 L2TP、PPTP、PPP认证方式;IPSec、GRE
运维·网络·网络协议·网络工程师·ip·软考·考试
小白自救计划5 小时前
网络协议分析 实验四 ICMPv4与ICMPv6
网络·网络协议
purrrew5 小时前
【Java ee初阶】网络编程 UDP socket
java·网络·网络协议·udp·java-ee
python算法(魔法师版)5 小时前
API安全
网络·物联网·网络协议·安全·网络安全
北漂老男孩6 小时前
网络协议与系统架构分析实战:工具与方法全解
网络·网络协议·系统架构