Interviewer: I heard you are familiar with OkHttp principle?

Recently, I plan to do network related optimization work. I have to familiarize myself with the network framework again. OkHttp is the leader of the network framework in the Android field. I take this opportunity to make an in-depth analysis of some internal implementations of OkHttp. At the same time, these questions are also frequent visitors during the interview. I believe they will be helpful to you.

Let's start with four soul torture combos:

  • What is the difference between addInterceptor and addNetworkInterceptor?
  • How is network caching implemented?
  • How to reuse network connections?
  • How does OkHttp do network monitoring?

Is it familiar and unfamiliar? In fact, it is because the network framework has realized these basic functions for us, so it is easy to be ignored by us. In order to fully analyze the above problems, we need to review the basic principles of OkHttp:

Basic implementation principle of OkHttp

The internal implementation of OkHttp is completed through a responsibility chain mode, encapsulating each stage of network request into each chain, and realizing the decoupling of each layer.

The source code in the text is based on the latest version 4.2.2 of OkHttp. Starting from version 4.0.0, OkHttp is developed in the whole Kotlin language. Children who don't get on the bus should pay close attention to it, or they can't understand the source code [cover their faces]. You can refer to the old text for learning Kotlin Kotlin Learning Series Overview .

Let's get familiar with the OkHttp execution process by starting a request call.

//Create OkHttpClient
val client = OkHttpClient.Builder().build();

//Create request
val request = Request.Builder()
           .url("https://wanandroid.com/wxarticle/list/408/1/json")
           .build()

//Synchronization task starts new thread execution
Thread {
    //Initiate network request
    val response = client.newCall(request).execute()
    if (!response.isSuccessful) throw IOException("Unexpected code $response")
    Log.d("okhttp_test", "response:  ${response.body?.string()}")
}.start()

Therefore, the core code logic is to create a call object through the newCall method of OkHttpClient and call its execute method; Call represents a network request interface, and the implementation class has only one RealCall. Execute means to initiate a network request synchronously, and the corresponding enqueue method means to initiate an asynchronous request, so it needs to pass in a callback at the same time.

Let's look at the execute method of RealCall:

# RealCall
override fun execute(): Response {
    ...
    //Start timing timeout, send request and start callback
    transmitter.timeoutEnter()
    transmitter.callStart()
    try {
      client.dispatcher.executed(this)//Step 1
      return getResponseWithInterceptorChain()//Step 2
    } finally {
      client.dispatcher.finished(this)//Step 3
    }
}

It only takes three steps to put the elephant in the refrigerator.

First step

Call the execute method of the Dispatcher. What is the Dispatcher? From its name, it is a scheduler. What does it schedule? That is, all network requests, that is, RealCall objects. Network requests support synchronous execution and asynchronous execution. Asynchronous execution requires thread pool and concurrency threshold. If the threshold is exceeded, the exceeded part needs to be stored. In this way, the functions of Dispatcher can be summarized as follows:

  • Record synchronous tasks, asynchronous tasks and asynchronous tasks waiting to be executed.
  • The thread pool manages asynchronous tasks.
  • Initiate / cancel network request API: execute, enqueue, cancel.

OkHttp sets the default maximum concurrent requests maxRequests = 64 and the maximum concurrent requests supported by a single host maxRequestsPerHost = 5.

These requests are stored in three double ended queues at the same time:

# Dispatcher
//Asynchronous task waiting queue
private val readyAsyncCalls = ArrayDeque<AsyncCall>()
//Asynchronous task queue
private val runningAsyncCalls = ArrayDeque<AsyncCall>()
//Synchronize task queue
private val runningSyncCalls = ArrayDeque<RealCall>()

Why use double ended queues? It's very simple, because the execution sequence of network requests is the same as queuing. Pay attention to first come, first served. New requests are put at the end of the queue, and the execution requests are taken from the opposite head.

When it comes to this, LinkedList is not satisfied. We know that LinkedList also implements the Deque interface, and the internal is a double ended queue implemented by linked list. Why not use LinkedList?

In fact, this is related to the conversion from readyAsyncCalls to runningAsyncCalls. When a request is executed or a new request is queued by calling enqueue method, readyAsyncCalls will be traversed, and those qualified waiting requests will be transferred to the runningAsyncCalls queue and handed over to the thread pool for execution. Although both of them can complete this task, because the data structure of the linked list causes the elements to be discretely distributed in various locations of the memory, the CPU cache can not bring much convenience. In addition, in garbage collection, the efficiency of using the array structure is better than that of the linked list.

Returning to the topic, the above core logic is in the promoteAndExecute method:

#Dispatcher
private fun promoteAndExecute(): Boolean {
    val executableCalls = mutableListOf<AsyncCall>()
    val isRunning: Boolean
    synchronized(this) {
      val i = readyAsyncCalls.iterator()
      //Traverse readyAsyncCalls
      while (i.hasNext()) {
        val asyncCall = i.next()
        //Threshold check
        if (runningAsyncCalls.size >= this.maxRequests) break // Max capacity.
        if (asyncCall.callsPerHost().get() >= this.maxRequestsPerHost) continue // Host max capacity.
        //Remove the eligible from the readyAsyncCalls list
        i.remove()
        //per host count plus 1
        asyncCall.callsPerHost().incrementAndGet()
        executableCalls.add(asyncCall)
        //Move into runningAsyncCalls list
        runningAsyncCalls.add(asyncCall)
      }
      isRunning = runningCallsCount() > 0
    }
    
    for (i in 0 until executableCalls.size) {
      val asyncCall = executableCalls[i]
      //Submit task to thread pool
      asyncCall.executeOn(executorService)
    }
    
    return isRunning
}

This method will be called in enqueue and finish methods, that is, when a new request is queued and the current request is completed, the task needs to be resubmitted to the thread pool.

Talking about the half path pool, what thread pool is used inside OkHttp?

#Dispatcher 
@get:JvmName("executorService") val executorService: ExecutorService
get() {
  if (executorServiceOrNull == null) {
    executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
        SynchronousQueue(), threadFactory("OkHttp Dispatcher", false))
  }
  return executorServiceOrNull!!
}

Isn't this a newCachedThreadPool? Yes, except for the last threadFactory parameter, it is the same as newCachedThreadPool, except that the thread name is set for troubleshooting.

The SynchronousQueue used for blocking queue is characterized by not storing data. When adding an element, you must wait for a consumer thread to take it out, otherwise it will be blocked all the time. If there is an idle thread at present, it will be executed directly in the idle thread. If not, a new thread will be started to execute the task. It is usually used in scenarios that require rapid response to tasks. It is more appropriate under the background that network requests require low latency. See the old article for details Analysis on the working principle of Java thread pool.

Continue to return to the main line. The second step is more complex. Let's skip first and look at the third step.

Step 3

Call the Dispatcher's finished method

//End of asynchronous task execution
internal fun finished(call: AsyncCall) {
    call.callsPerHost().decrementAndGet()
    finished(runningAsyncCalls, call)
}

//End of synchronization task execution
internal fun finished(call: RealCall) {
    finished(runningSyncCalls, call)
}

//Synchronous and asynchronous tasks are summarized here
private fun <T> finished(calls: Deque<T>, call: T) {
    val idleCallback: Runnable?
    synchronized(this) {
      //Remove completed tasks from the queue
      if (!calls.remove(call)) throw AssertionError("Call wasn't in-flight!")
      idleCallback = this.idleCallback
    }
    //This method has been analyzed in the first step to move the requests in the waiting queue into the asynchronous queue and hand it over to the thread pool for execution.
    val isRunning = promoteAndExecute()
    
    //If no request needs to be executed, the callback is idle
    if (!isRunning && idleCallback != null) {
      idleCallback.run()
    }
}

Step 2

Now let's go back to the second most complex step and call the getResponseWithInterceptorChain method, which is also the core of the whole OkHttp implementation of the responsibility chain pattern.

#RealCall
fun getResponseWithInterceptorChain(): Response {
    //Create interceptor array
    val interceptors = mutableListOf<Interceptor>()
    //Add application interceptor
    interceptors += client.interceptors
    //Add retry and redirect interceptors
    interceptors += RetryAndFollowUpInterceptor(client)
    //Add bridge interceptor
    interceptors += BridgeInterceptor(client.cookieJar)
    //Add cache interceptor
    interceptors += CacheInterceptor(client.cache)
    //Add connection interceptor
    interceptors += ConnectInterceptor
    if (!forWebSocket) {
      //Add network interceptor
      interceptors += client.networkInterceptors
    }
    //Add request interceptor
    interceptors += CallServerInterceptor(forWebSocket)
    
    //Create a chain of responsibility
    val chain = RealInterceptorChain(interceptors, transmitter, null, 0, originalRequest, this,
        client.connectTimeoutMillis, client.readTimeoutMillis, client.writeTimeoutMillis)
    ...
    try {
      //Start the chain of responsibility
      val response = chain.proceed(originalRequest)
      ...
      return response
    } catch (e: IOException) {
      ...
    }
  }

We don't care what each interceptor does, and the main process finally comes to chain proceed(originalRequest). Let's take a look at the proceed method:

  # RealInterceptorChain
  override fun proceed(request: Request): Response {
    return proceed(request, transmitter, exchange)
  }

  @Throws(IOException::class)
  fun proceed(request: Request, transmitter: Transmitter, exchange: Exchange?): Response {
    if (index >= interceptors.size) throw AssertionError()
    // Count the number of times the current interceptor calls the proceed method
    calls++

    // Exhage is the encapsulation of the request flow. It is empty before executing ConnectInterceptor. The connection and flow have been established, but this connection no longer supports the current url
    // The previous network interceptor has modified the url or port, which is not allowed!!
    check(this.exchange == null || this.exchange.connection()!!.supportsUrl(request.url)) {
      "network interceptor ${interceptors[index - 1]} must retain the same host and port"
    }

    // Here are the restrictions on interceptors calling the proceed method. Interceptors in ConnectInterceptor and beyond can only call proceed once at most!!
    check(this.exchange == null || calls <= 1) {
      "network interceptor ${interceptors[index - 1]} must call proceed() exactly once"
    }

    // Create the next layer of responsibility chain note index + 1
    val next = RealInterceptorChain(interceptors, transmitter, exchange,
        index + 1, request, call, connectTimeout, readTimeout, writeTimeout)

    //Take out the interceptor with index subscript and call its intercept method to pass in the new chain.
    val interceptor = interceptors[index]
    val response = interceptor.intercept(next) 

    // Ensure that the process is called at least once in ConnectInterceptor and its subsequent interceptors!!
    check(exchange == null || index + 1 >= interceptors.size || next.calls == 1) {
      "network interceptor $interceptor must call proceed() exactly once"
    }

    return response
  }

The comments in the code have been clearly written. To sum up, create the next level responsibility chain, then take out the current interceptor, call its intercept method and pass in the created responsibility chain++ In order to ensure that the responsibility chain can proceed in turn, it must be ensured that except for the last interceptor (CallServerInterceptor), all interceptor intercept methods must call chain once The processed () method is + +, so that the whole responsibility chain runs.

For example, in the ConnectInterceptor source code:

# ConnectInterceptor uses a singleton here
object ConnectInterceptor : Interceptor {

  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
    val realChain = chain as RealInterceptorChain
    val request = realChain.request()
    val transmitter = realChain.transmitter()

    val doExtensiveHealthChecks = request.method != "GET"
    //Create connections and flows
    val exchange = transmitter.newExchange(chain, doExtensiveHealthChecks)
    //Implement the next level of responsibility chain
    return realChain.proceed(request, transmitter, exchange)
  }
}

In addition, different nodes in the responsibility chain have different restrictions on the number of calls to proceed. The ConnectInterceptor interceptor and its subsequent interceptors can and can only be called once, because the work of network handshake, connection and sending requests takes place in these interceptors, indicating that a network request has been formally sent; Before that, the interceptor can execute multiple procedures, such as error retry.

After the responsibility chain is pushed down level by level, it will eventually execute the intercept method of CallServerInterceptor, which will encapsulate the result of the network Response into a Response object and return. After that, it goes back level by level along the responsibility chain, and finally returns to the return of the getResponseWithInterceptorChain method.

Interceptor classification

Now we need to roughly summarize the functions of interceptors at each node of the responsibility chain:

Interceptoreffect
Application interceptorWhat you get is the original request. You can add some custom header s, general parameters, parameter encryption, gateway access, etc.
RetryAndFollowUpInterceptorHandling error retries and redirects
BridgeInterceptorThe bridge interceptors at the application layer and network layer mainly work to add cookies and fixed header s for requests, such as Host, content length, content type, user agent, etc., and then save the cookies of the response results. If the response is compressed with gzip, it also needs to be decompressed.
CacheInterceptorCache interceptor. If the cache is hit, the network request will not be initiated.
ConnectInterceptorThe connection interceptor internally maintains a connection pool, which is responsible for connection reuse, creating connections (three handshakes, etc.), releasing connections, and creating socket streams on connections.
Network interceptorsUser defined interceptors are usually used to monitor data transmission at the network layer.
CallServerInterceptorThe request interceptor actually initiates the network request after the pre preparation is completed.

So far, the core execution process of OkHttp is over. Does it feel suddenly enlightened? Now we can finally answer the opening question:

The difference between addInterceptor and addNetworkInterceptor

The common names of the two are application interceptor and network interceptor. From the perspective of the whole responsibility link, the application interceptor is the first interceptor to execute, that is, the original request after the user sets the request attribute. The network interceptor is located between ConnectInterceptor and CallServerInterceptor. At this time, the network link is ready and only waiting to send the request data.

  1. First, the application interceptor precedes RetryAndFollowUpInterceptor and CacheInterceptor, so in case of error retry or network redirection, the network interceptor may execute multiple times, because it is equivalent to making a second request, but the application interceptor will always be triggered only once. In addition, if the cache is hit in the CacheInterceptor, the network request does not need to be sent, so there will be a short circuit to the network interceptor.
  2. Secondly, as mentioned above, each interceptor should call realchain at least once except CallServerInterceptor Proceed method. In fact, in the application interceptor layer, the proceed method can be called multiple times (local exception retry) or not (interrupt), but the connection of the network interceptor layer is ready, and the proceed method can be called only once.
  3. Finally, from the usage scenario, the application interceptor is usually used to count the initiation of network requests from clients because it is only called once; One call of network interceptor means that a network communication will be initiated, so it can usually be used to count the data transmitted on the network link.

Network caching mechanism CacheInterceptor

The cache here refers to the data cache strategy based on the Http network protocol, focusing on the client cache, so let's review how the Http protocol identifies the availability of the cache according to the request and response headers.

When it comes to caching, we must talk about the effectiveness and validity period of caching.

HTTP caching principle

In the HTTP 1.0 era, the response uses the expires header to identify the validity of the cache, and its value is an absolute time, such as Expires:Thu,31 Dec 2020 23:59:59 GMT. When the client sends a network request again, it can compare the current time with the expires time of the last response to decide whether to use the cache or initiate a new request.

The biggest problem with using the Expires header is that it depends on the local time of the client. If the user modifies the local time, it will not be able to accurately judge whether the cache Expires.

Therefore, starting from HTTP 1.1, the cache control header is used to indicate the cache status. Its priority is higher than Expires. The common values are one or more of the following.

  • Private, the default value, identifies private business logic data, such as recommendation data issued according to user behavior. In this mode, the proxy server and other nodes in the network link should not cache this part of data, because it has no practical significance.
  • Contrary to private, public is used to identify common business data, such as obtaining news lists. All people see the same data, so the client and proxy server can cache it.
  • No cache can be used for caching, but before the client uses the cache, the server must verify the effectiveness of cache resources, that is, the comparison cache section below, which will be introduced later.
  • Max age indicates the cache duration, in seconds. It refers to a time period, such as a year. It is usually used for static resources that do not change frequently.
  • No store any node is prohibited from using cache.

Force cache

Based on the above cache header protocol, forced caching means that the network request response header identifies Expires or cache control with Max age information. At this time, if the client computing cache has not expired, you can directly use the local cache content without actually initiating a network request.

Negotiation cache

The biggest problem with forced caching is that once the server resources are updated, the client cannot obtain the latest resources until the cache time expires (unless the no store header is manually added during the request). In addition, in most cases, the server resources cannot directly determine the cache expiration time, so it is more flexible to use contrast caching.

Use the last modify / if modify since header to realize negotiation caching. The specific method is to add the last modify header to the server response header to identify the last modification time of the resource, in seconds. When the client initiates the request again, add the if modify since header and assign it to the value of the last modify header obtained in the last request.

After receiving the request, the server determines whether the cache resource is still valid. If it is valid, it returns the status code 304 and the body body is empty. Otherwise, it issues the latest resource data. If the client finds that the status code is 304, it takes out the local cached data as a response.

One problem with using this scheme is that the last modification time of resource files has certain limitations:

  1. Last modify is in seconds. If some files are modified within one second, the modification time cannot be accurately identified.
  2. The resource modification time cannot be used as the only basis for whether the resource is modified. For example, the resource file is Daily Build, and a new one will be generated every day, but its actual content may not be changed.

Therefore, HTTP also provides another set of header information to process the cache, ETag / if none match. The process is the same as last modify, except that the header of the server response becomes last modify and the header sent by the client becomes if none match. ETag is the unique identifier of the resource. The change of the server resource will certainly lead to the change of ETag. The specific generation method includes server-side control. The influencing factors of the scene include the final modification time, file size, file number, etc.

Cache implementation of OKHttp

So much has been said above. In fact, OKHttp implements the above process in code, that is:

  1. After getting the response for the first time, decide whether to cache according to the header information.
  2. Next time, judge whether there is a local cache, whether it is necessary to use a comparison cache, encapsulate the request header information, and so on.
  3. If the cache fails or needs to be compared, issue a network request, otherwise use the local cache.

OKHttp uses Okio internally to read and write cache files.

Cache files are divided into CleanFiles and DirtyFiles. CleanFiles are used for reading and DirtyFiles are used for writing. They are arrays with a length of 2, representing two files, namely, the request header and request body of the cache; At the same time, the cache operation log is recorded in the journalFile.

To enable Cache, you need to set a Cache object when OkHttpClient is created, and specify the Cache directory and Cache size. LRU is used as the elimination algorithm of Cache in the Cache system.

## Cache.kt
class Cache internal constructor(
  directory: File,
  maxSize: Long,
  fileSystem: FileSystem
): Closeable, Flushable

Earlier versions of OkHttp had an InternalCache interface that supported custom implementation caching, but by 4 After the version of X, the InternalCache is deleted, and the Cache class is final, which is equivalent to turning off the extension function.

The specific source code implementation is in the CacheInterceptor class, which you can refer to by yourself.

The cache is set to be global through OkHttpClient. If we want to use or disable the cache for a specific request, we can implement it through the relevant API of CacheControl:

//disable cache 
Request request = new Request.Builder()
    .cacheControl(new CacheControl.Builder().noCache().build())
    .url("http://publicobject.com/helloworld.txt")
    .build();

Caching not supported by OKHttp

Finally, it should be noted that OKHttp only supports the cache of get requests by default.

# okhttp3.Cache.java
@Nullable CacheRequest put(Response response) {
    String requestMethod = response.request().method();
    ...
    //Cache only supports GET requests
    if (!requestMethod.equals("GET")) {
      // Don't cache non-GET responses. We're technically allowed to cache
      // HEAD requests and some POST requests, but the complexity of doing
      // so is high and the benefit is low.
      return null;
    }
    
    //When the value of the variable header is *, the unified cache is not used
    if (HttpHeaders.hasVaryAll(response)) {
      return null;
    }
    ...
}

This is the logical code when the network request responds and is ready for caching. When null is returned, it means no caching. It is not difficult to see from the code comments that we can technically cache the method as HEAD and some POST requests, but the implementation complexity is very high and the benefit is very little. This is essentially determined by the usage scenarios of each method.

Let's take a look at the common method types and their uses.

  • GET requests resources, and the parameters are in the URL.
  • HEAD is basically the same as GET, except that it does not return the message body. It is usually used in speed or bandwidth priority scenarios, such as checking resource availability and accessibility.
  • POST submit the form, modify the data, and the parameters are in the body.
  • PUT and POST are basically the same. The biggest difference is that PUT is idempotent.
  • DELETE deletes the specified resource.

You can see that for standard RESTful requests, GET is used to obtain data, which is most suitable for caching. For other data operations, caching has little significance or does not need caching at all.

Based on this, when only GET requests are supported, OKHTTP uses the request URL as the cached key (of course, it will go through a series of summary algorithms).

Finally, as posted in the above code, if the request header contains variable: * such header information will not be cached. The variable header is used to improve the cache hit rate of multi terminal requests. For example, two clients, one supports gzip compression and the other does not. Their request URL s are the same, but accept encoding is different, which can easily lead to cache confusion. We can declare variable: accept encoding to prevent this from happening.

The variable: * header indicates that the request is unique and should not be cached. Unless it is intentionally done, it will not sacrifice cache performance.

Learning resource sharing

In order to help you better learn the principles of frameworks, I would like to share with you a PDF version of Google's open source source source code analysis of top 100 frameworks, a complete version of source code analysis related to Android development, Click here to see the whole content . Or click[ here ]View the acquisition method.

Relevant video recommendations:

[2021 latest version] Android studio installation tutorial + Android zero foundation tutorial video (suitable for Android 0 foundation and introduction to Android) including audio and video_ Beep beep beep_ bilibili

[advanced Android tutorial] - OkHttp principle_ Beep beep beep_ bilibili

Interpretation of the principle of Android OkHttp -- take you to deeply master the development of OkHttp distributor and interceptor_ Beep beep beep_ bilibili

[advanced Android tutorial] - principle analysis of available network framework based on Okhttp_ Beep beep beep_ bilibili

Keywords: Android Programmer

Added by CSB on Fri, 07 Jan 2022 11:52:22 +0200