okhttp实现网络缓存实践

吐槽

关于利用okhttp的拦截器实现缓存在网上大概能找到很多相关的文章,在自己去实现post缓存的时候也看了一些文章,总体给我的感觉吧,都没有一个具体的过程,很多文章都是清一色添个代码,弄个测试demo就说实现了缓存功能,任何功能的实现肯定是基于现有场景的一些问题去改进,包括现在想写的这篇文章,也是基于公司当前的场景,无脑拿去拷贝使用肯定是有问题,我相信网上的大部分缓存实现拦截器也是这种情况。但是万变不离其宗,如果能搞懂okhttp的内部源码,再结合自己公司的具体业务逻辑,写一个符合场景的缓存拦截器肯定是没有问题的。

前提

这篇文章所写的内容都是针对公司的现有场景下,和网上的缓存实现上存在一些差异,具体的场景是这样的:公司主要做的是金融类的app,考虑到数据的实时性原先的app实现中没有缓存的相关需求,每次请求都是直接通过网络获取,但是对于一些实时性没这么高的页面,每次总走网络获取数据是不是在响应速度上差点意思,和小组内部成员讨论后自己就决定增加缓存的功能。

问题点

可能有人会有疑问,增加okhttp的缓存应该很简单,okhttp天然就支持了网络缓存的功能,几行设置代码就可以搞定。但是公司这边的网络请求存在一个比较奇葩的地方在于

所有的网络请求都是post来操作的!!!

如果你直接使用get来请求数据,后台已经设置了过滤器会直接把get请求给过滤掉,这就很难受了,熟悉okhttp的应该都知道okhttp只支持get请求网络缓存,对于post请求是不会进行缓存的,但公司这边的情况就是只能使用post请求,所以现在的问题就很明白了就是:

要实现网络缓存支持post的请求方式

其实这个问题就最简单的解决方式就是直接让后台不要过滤掉get请求,这样就可以直接走okhttp的现有缓存功能来实现,但理想很美好现实很骨感,后台这套逻辑历史原因吧由来已久,贸然改动可能会存在较大的风险,所以风险最小的方式就是移动端进行修改

post更加安全?

get请求会将请求参数放入链接,会将一些重要参数暴露出去。get请求长度有限超过之后会被截断,post请的参数被保存到请求体中更加安全,这种说法网上能找到很多。但这种说法实际认为站不住脚,首先请求放在get请求会被看到,那么放在请求体就一定安全?抓个包看下post请求不就知道了,要想安全至少需要对参数进行加密。get请求的参数经过加密处理同样安全。
get请求长度有限,这个问题网上搜一下就知道各大浏览器对于get请求长度限制不一样,但是确保正常get请求参数够用绰绰有余,除非你的get请求参数巨长,否则这个所谓的长度有限说法站不住脚。
由此看来使用post并没有更加安全高效,在服务端支持get请求的情况下,使用get请求完全没有问题。实际上这个问题也去问过后台开发,为什么不使用get请求做查询处理,答曰历史原因,至于是什么历史原因也就没人知道了

客户端实现post网络缓存相关问题

既然不能麻烦服务端的大佬们去修改,那么只有自己动手去修改网络请求库相应的实现了,如果真要实现post请求缓存,完全可以参考okhttp实现get缓存的方案,但是个人认为这种改动有点麻烦,okhttp也没有暴露相应的api供我们使用,okhttp在实现get缓存的时候,会将请求头和请求体分别保存在不同的文件中,在每次请求之后还会同步去修改里面的请求发送时间,响应达到时间,使用disklrucache结合okio库实现缓存文件的读写,删除等操作,所以如果真要实现post的缓存,那么你就必须考虑到上述的情况,网络有一种做法就是拷贝disklrucache相关源码进行封装然后缓存post请求,还是觉得有点麻烦,那么有没有更好的方法去实现post缓存?

post缓存实现方案

说下自己的思路,现在要缓存的是post请求,其实不管是缓存get还是post,只能能缓存住相应的请求就是好方案,何必在意它是post还是get。既然okhttp已经给我们实现了一套完整的get缓存方案,那么有什么办法可以让okhttp帮我们把post缓存请求也给处理掉,脑洞比较大,自己想到了一个偷梁换柱的方案,使用okhttp的拦截器在请求发送之前先对get请求进行处理,这里有人会有疑问,不是说你们公司不支持get请求,怎么还是使用get请求数据啊,这就是我说的偷梁换柱的方案,先发送get请求,获取到get请求中的参数,在即将发送到网络之前再重新将get请求转换为post,那么公司这边的后台就不会过滤掉这个请求了,在请求响应之后,再重新将post转换成get请求,确保CacheInterceptor可以缓存。

费这么大劲原因其实就两个:
(1)避免后台过滤get请求
(2)借助okhttp的缓存逻辑来帮我们缓存post请求,这里的post请求是针对发出去的请求而已,实际上在okhttp看来就是一个get请求
那么最关键的问题就变成了这种偷梁换柱的方案是否可行?

可行性

分析下方案的可行性,首先大家都知道okhttp一个设计精髓就是使用了拦截器的形式来处理各种请求,先看下拦截器的顺序

Response getResponseWithInterceptorChain() throws IOException {
    // Build a full stack of interceptors.
    List interceptors = new ArrayList<>();
    interceptors.addAll(client.interceptors());
    interceptors.add(retryAndFollowUpInterceptor);
    interceptors.add(new BridgeInterceptor(client.cookieJar()));
    interceptors.add(new CacheInterceptor(client.internalCache()));
    interceptors.add(new ConnectInterceptor(client));
    if (!forWebSocket) {
      interceptors.addAll(client.networkInterceptors()); 
    }
    interceptors.add(new CallServerInterceptor(forWebSocket));

    Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
        originalRequest, this, eventListener, client.connectTimeoutMillis(),
        client.readTimeoutMillis(), client.writeTimeoutMillis());

    return chain.proceed(originalRequest);
  }

除了内置的系统拦截器,我们可以给okhttp添加两类拦截器,缺一不可,这也是我实现post缓存的关键点,client.interceptors()和client.networkInterceptors(),这两类拦截器正好夹在了CacheInterceptor中间,也就是说可以做到在interceptors中先对get请求进行初步处理,然后在networkInterceptors请求中将get请求转换成post发送,那么CacheInterceptor自然可以缓存get请求,而服务端又可以正常接收到post请求,一举两得,即可以利用现有的CacheInterceptor缓存数据,又可以巧妙避开公司服务端过滤get请求的问题,完美!!!

方案可能的问题点

原理已经在上面说明白了,剩下的就是如何实现了,说一下可能会遇到的问题
1 请求被缓存后,手机上不同的账号发起同一个请求会不会导致只读缓存的问题
为了避免这个问题需要在请求参数中带上们每一个请求的标志符确保唯一性,标志符可以选择token,因为每次请求都会有用户的token,利用token可以确保请求的唯一性,做到多用户缓存各自的数据

2 如果服务端的响应数据中显式指定不缓存,需要考虑数据不缓存的情况
理论上网络请求的缓存实现,需要移动端和服务端配合去实现,对于服务端显式指定不缓存的数据,不要画蛇添足给缓存起来,所以在实现的时候需要考虑到这种情况,我们只缓存服务端没有显式指定要缓存的功能

3 使用拦截器进行get,post转换会不会存在一些未知的坑点
这个问题应该是在实现的过程中最关心的一点,需要结合okhttp的源码确保这种转换没有问题,因为okhttp内部存在连接池复用的机制,确保在一条get的请求连接上进行post请求没有问题

4 当okhttp并发发起多个请求时,会不会导致缓存异常问题
okhttp是支持并发发起网络请求的!!!,如果你在拦截器中使用了一些全局变量需要考虑缓存异常的情况出现,做好同步处理

5 网络成功请求后缓存数据的保留问题
并不是说网络请求成功就一定要缓存数据,这里说的网络请求成功仅仅指返回200的情况,大部分情况下内部都会定义一些errcode,如果此时errcode不为成功不要缓存数据,这里就涉及到从responseBody读取流进行解析的问题

6 如何做到在不影响现有功能的情况下,低侵入接入post缓存
接入的原则肯定不能对已有的网络请求造成影响,在使用缓存功能的请求时尽量和现有方式保持一致,降低小组其他成员的使用成本

上面就是在实现post缓存过程中考虑的问题,结合这些问题看下代码实现

Cache

一个工具类,封装了几个方法

public class Cache {

    private static final String okCACHE_FILE = "okHttpCache";
    private static okhttp3.Cache okHttpCache;
    static ThreadLocal local = new ThreadLocal<>();
    public static boolean visible;

    public synchronized static okhttp3.Cache createCache() {
        if (okHttpCache == null) {
            okHttpCache = new okhttp3.Cache(new File(ContextCompat.getDataDir(BaseApplication.getGlobalContext()), okCACHE_FILE), 10 * 1024 * 1024);
        }
        return okHttpCache;
    }

    /**
     * okHttp拦截器之间传递数据保存类
     */
    static class Temp {
        String key;
        String maxAge;
    }

    /**
     * okHttp缓存清除
     */
    public static void cleanCache(boolean memoryCacheOnly) {
        try {
            if (!memoryCacheOnly) {
                File parentFile = new File(ContextCompat.getDataDir(BaseApplication.getGlobalContext()), okCACHE_FILE);
                if (!parentFile.exists()) {
                    return;
                }
                deleteCacheFile(parentFile.getAbsolutePath());
            }
            if (okHttpCache != null) {
                okHttpCache.evictAll();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 删除okHttp缓存中文件
     */
    @SuppressWarnings("ResultOfMethodCallIgnored")
    private static void deleteCacheFile(String filePath) {
        if (!TextUtils.isEmpty(filePath)) {
            File file = new File(filePath);
            if (file.isDirectory()) {
                File[] files = file.listFiles();
                if (DataUtil.isEmpty(files)) {
                    return;
                }
                for (File item : files) {
                    if ("journal".equalsIgnoreCase(item.getName())) {
                        continue;
                    }
                    item.delete();
                }
            }
        }
    }
}

说下几个地方
(1)visible字段作用:这边在缓存post数据之前如果发送回来的数据没有gzip压缩那么会进行一次gzip压缩处理。导致的一个问题就是缓存的数据全是压缩后的乱码,为了方便调试增加了该字段,在debug模式下不进行gzip压缩,方便查看缓存数据
(2)local字段作用:还记得我上述文章讲到的okhttp并发请求问题,使用local配置temp就是为了解决这个问题
(3)Temp类:为了实现post缓存使用了两个拦截器,拦截器之间传递数据就是通过Temp

okhttp生成

生成全局client类

OkHttpClient.Builder builder = createOkHttpBuilder(apiConfig)
                    .addInterceptor(new BaseInterceptor(apiConfig))
                    .addInterceptor(new PreProcessInterceptor())
                    .addNetworkInterceptor(new CacheInterceptor());
            builder.cache(Cache.createCache());
            OkHttpClient client = builder.build();
            Retrofit.Builder retrofitBuilder = new Retrofit.Builder()
                    .client(client)
                    .baseUrl(apiConfig.getBaseUrl())
                    .addConverterFactory(FastJsonConverterFactory.create())
                    .addCallAdapterFactory(RxJava2CallAdapterFactory.create());
            if (factory != null) {
                retrofitBuilder.addConverterFactory(factory);
            }
            mRetrofit = retrofitBuilder.build();

BaseInterceptor无需关心,添加了一些请求参数,和具体业务逻辑相关。关键点就是PreProcessInterceptor和CacheInterceptor实现了post缓存

PreProcessInterceptor实现

public class PreProcessInterceptor implements Interceptor {

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        String method = request.method();
        if (!("GET".equalsIgnoreCase(method))) {
            return chain.proceed(request);
        }
        HttpUrl url = request.url();
        if (url.querySize() != 2) {        //和具体业务相关无需关心
            return chain.proceed(request);
        }
        Cache.Temp temp = new Cache.Temp();
        temp.key = url.queryParameterValue(0);
        temp.maxAge = url.queryParameterValue(1);
        Cache.local.set(temp);
        String query = url.query();
        if (TextUtils.isEmpty(query)) {
            return chain.proceed(request);
        }
        String hex = ByteString.encodeUtf8(query).md5().hex();

        String path = url.scheme() + "://" + url.host() + url.encodedPath() + "?compose=" + hex;
        request = request.newBuilder()
                .url(path)
                .build();
        return chain.proceed(request);
    }
}

PreProcessInterceptor作用就是初步处理get请求,将get请求中的参数保存起来,并将参数转换成了md5格式,之所以转换成md5主要考虑的问题就是get参数中携带了一些关键数据,如果该请求被缓存不进行md5处理的情况下,可以直接在缓存文件中查看到这些关键数据,PreProcessInterceptor相对简单一点就是对数据的保存,转换工作

CacheInterceptor实现

public class CacheInterceptor implements Interceptor {

    private final static String FIELD = "success";

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        Cache.Temp temp = Cache.local.get();             
        if ("POST".equalsIgnoreCase(request.method()) || temp == null) {
            return chain.proceed(request);
        }
        try {
            Integer.valueOf(temp.maxAge);
        } catch (Exception e) {
            temp.maxAge = "60";
        }
        HttpUrl httpUrl = request.url();
        if (httpUrl.querySize() != 1 || TextUtils.isEmpty(temp.key)) {
            return chain.proceed(request);
        }

        String encryptRSA = temp.key;
        if (AppConfig.getInstance().isEncryptEnabled()) {
            String publicKey = AppConfig.getInstance().getPublicKey();
            encryptRSA = EncryptUtil.encryptRSA(temp.key, publicKey);            
        }

        MediaType mediaType = MediaType.parse("application/json; charset=UTF-8");
        RequestBody body = RequestBody.create(mediaType, encryptRSA);

        Request.Builder builder = request.newBuilder()
                .url(httpUrl.scheme() + "://" + httpUrl.host() + httpUrl.encodedPath())
                .post(body)
                .header("Content-Type", "application/json; charset=UTF-8");

        long contentLength = body.contentLength();
        if (contentLength != -1) {
            builder.header("Content-Length", Long.toString(contentLength));
            builder.removeHeader("Transfer-Encoding");
        }
        Request modifyRequest = builder.build();
        Response response = chain.proceed(modifyRequest);

        if (response.code() == 200 && HttpHeaders.hasBody(response)) {
            String cache = response.header("Cache-Control");
            if (!TextUtils.isEmpty(cache) && (cache.contains("no-cache") || cache.contains("no-store"))) {
                return response;
            }
            if (TextUtils.isEmpty(cache)) {
                cache = "max-age=" + temp.maxAge;
            } else if (!cache.contains("max-age")) {
                cache += ",max-age=" + temp.maxAge;
            }
            Response.Builder modifiedResponse = response.newBuilder()
                    .request(request)
                    .header("Cache-Control", cache);

            String contentType = response.header("Content-Type");
            long length = HttpHeaders.contentLength(response);
            if ("chunked".equalsIgnoreCase(response.header("Transfer-Encoding"))) {
                length = -1;
            }

            String encoding = response.header("Content-Encoding");
            boolean gzip = "gzip".equalsIgnoreCase(encoding);
            return assembleResponse(response, gzip, contentType, length, modifiedResponse);
        }
        return response;
    }

    private Response assembleResponse(Response response, boolean gzip,
                                      String contentType, long length,
                                      Response.Builder modifiedResponse) throws IOException {
        RealResponseBody responseBody = null;

        if (!gzip) {
            Buffer buffer = new Buffer();
            Response result = checkAndProcessResponse(response, false, buffer, contentType, length);
            if (result != null) {
                return result;
            }

            if (!Cache.visible) {
                Buffer dst = new Buffer();
                BufferedSink bufferedSink = Okio.buffer(new GzipSink(dst));
                bufferedSink.writeAll(buffer);
                bufferedSink.close();
                responseBody = new RealResponseBody(contentType, length, dst);
            }
        } else {
            Buffer buffer = new Buffer();
            Response result = checkAndProcessResponse(response, true, buffer, contentType, length);
            if (result != null) {
                return result;
            }
            responseBody = new RealResponseBody(contentType, length, buffer);
        }

        if (responseBody != null) {
            modifiedResponse.addHeader("Content-Encoding", "gzip");
            modifiedResponse.body(responseBody);
        }
        return modifiedResponse.build();
    }

    private Response checkAndProcessResponse(Response response, boolean gzip, Buffer buffer,
                                             String contentType, long length) throws IOException {
        ResponseBody resBody = response.body();
        if (resBody == null) {
            return response;
        }
        BufferedSource source = resBody.source();
        source.readAll(buffer);
        Buffer clone = buffer.clone();

        String content;
        if (gzip) {
            BufferedSource bufferedSource = Okio.buffer(new GzipSource(clone));
            content = bufferedSource.readUtf8();
            bufferedSource.close();
        } else {
            content = clone.readUtf8();
        }
        if (AppConfig.getInstance().isResponseEncryptEnable()) {
            String responsePublicKey = AppConfig.getInstance().getResponsePrivateKey();
            content = EncryptUtil.decryptRSA(content, responsePublicKey);
        }
        source.close();
        clone.clear();
        try {
            JSONObject bean = new JSONObject(content);
            if (!(bean.optBoolean(FIELD, false))) {
                return response.newBuilder().body(new RealResponseBody(contentType, length, buffer)).build();
            }
        } catch (Exception e) {
            e.printStackTrace();
            return response.newBuilder().body(new RealResponseBody(contentType, length, buffer)).build();
        }
        return null;
    }
}

该拦截器主要两个作用,发送网络请求之前将get请求转换成post请求,数据返回之后重新转换为get的方式。
在实现get请求转换成post请求时,需要将get请求中的参数放到请求体中,重新构建一个post的请求发送出去,拦截器中使用到的key和maxage都是通过PreProcessInterceptor得到,CacheInterceptor中涉及到的rsa加密对请求体中的数据进行加密确保了安全性,完成加密之后就是get请求转换成post请求时需要注意的几个请求头字段转换问题,最后将数据发送出去。

在数据接收到之后,需要将原先的post请求转换成get请求,考虑到gzip压缩的情况做了一些额外处理。之后对数据正确性进行了校验,只有校验数据正确才会真正进行缓存,在返回响应体之前构造了Cache-Control字段明确缓存的时间。

请求方式修改

文章之前说过发起的请求实际上是get请求,经过转换之后变成post真正发送出去,先看一下原先的调用方式

@POST("xxx/xxx/queryNoticeDetail")
Flowable> queryNoticeDetail(@Body QueryNoticeDetailReqBean reqBean);

使用retrofit结合rxjava2完成post请求。将上述请求修改成支持缓存的请求方式很简单

@GET("xxx/xxx/queryNoticeDetail")
Flowable> queryNoticeDetail(@Query("key") QueryNoticeDetailReqBean reqBean,@Query("max-age")int maxAge);

所以项目中原先的请求保留post的请求方式就不会走缓存逻辑,对原有的请求接口没有任何影响。新功能开发时可以根据具体情况来确定是否使用缓存,为了支持上述接口的改动需要在retrofit中增加一个addConverterFactory调用,确保将bean转换成json形式,具体实现比较简单根据自己项目需要转换即可。

get请求转post最后一个问题

看下okhttp自带的拦截器ConnectInterceptor

public final class ConnectInterceptor implements Interceptor {
  public final OkHttpClient client;

  public ConnectInterceptor(OkHttpClient client) {
    this.client = client;
  }

  @Override public Response intercept(Chain chain) throws IOException {
    RealInterceptorChain realChain = (RealInterceptorChain) chain;
    Request request = realChain.request();
    StreamAllocation streamAllocation = realChain.streamAllocation();

    // We need the network to satisfy this request. Possibly for validating a conditional GET.
    boolean doExtensiveHealthChecks = !request.method().equals("GET");
    HttpCodec httpCodec = streamAllocation.newStream(client, chain, doExtensiveHealthChecks);
    RealConnection connection = streamAllocation.connection();

    return realChain.proceed(request, streamAllocation, httpCodec, connection);
  }
}

该拦截器位于CacheInterceptor之后,注意里面的关键代码

  boolean doExtensiveHealthChecks = !request.method().equals("GET");
    HttpCodec httpCodec = streamAllocation.newStream(client, chain, doExtensiveHealthChecks);

可以看到会对请求方式进行判断,如果是post请求会设置为true,get请求传入false,那么对我们的post缓存实现会有什么影响吗,追踪下具体doExtensiveHealthChecks的使用场景

public boolean isHealthy(boolean doExtensiveChecks) {
    if (socket.isClosed() || socket.isInputShutdown() || socket.isOutputShutdown()) {
      return false;
    }

    if (http2Connection != null) {
      return !http2Connection.isShutdown();
    }

    if (doExtensiveChecks) {
      try {
        int readTimeout = socket.getSoTimeout();
        try {
          socket.setSoTimeout(1);
          if (source.exhausted()) {
            return false; // Stream is exhausted; socket is closed.
          }
          return true;
        } finally {
          socket.setSoTimeout(readTimeout);
        }
      } catch (SocketTimeoutException ignored) {
        // Read timed out; socket is good.
      } catch (IOException e) {
        return false; // Couldn't read; socket is closed.
      }
    }

    return true;
  }

可以看到if分支中会对链接进行一个判断,在链接存在可用的情况下会复用该链接,否则返回false,重新建立一个新的链接,经过自己跟踪源码唯一发现post和get在链接使用的一个区别就在于这里,其他地方没有什么区别,经过测试自己的post缓存方案可以正常缓存数据,且不对现有代码造成任何影响,也算没有白费在实现过程中的各种踩坑

总结

到此关于利用拦截器实现post的方案就全部交代完毕了,在整个实现过程中也是不停debug okhttp源码的一个过程,可以说对于okhttp源码又有了一个更深刻的理解,当然不管我文章所说的缓存方案还是网上的缓存方案都存在一定的局限性,不可能一个缓存方法适用于所有的场景,具体的实现还需要根据公司的具体业务进行修改,但是只有在理解原理的基础上才能大胆放心的进行修改

你可能感兴趣的:(okhttp实现网络缓存实践)