Okhttp3+Retrofit實現POST請求緩存鏈

前言

按照HTTP緩存機制和REST API設計規范,我們不應該緩存POST請求結果, 所以Okhttp官方也沒有實現對POST請求結果進行緩存,以下是Okhttp源碼注釋

// 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.

You can't cache POST requests with OkHttp’s cache. You’ll need to store them using some other mechanism

但是現實社會很殘酷,由于各種原因, 我們身邊有很多用 POST請求當作GET使用請求的API. 對于這種情況,我們就要自己實現POST請求緩存鏈了.

本文將講述Okhttp3+Retrofit實現POST請求緩存鏈過程, 當然也可以用于GET請求,但是不建議那么做,因為Okhttp對緩存GET請求支持的很完美.

特點

如果緩存有數據,并且數據沒有過期,那么直接取緩存數據;

如果緩存過期,則直接從網絡獲取數據;

支持直接從緩存中讀數據

支持忽略緩存,直接從網絡獲取數據

支持自由精確地配置緩存有效時間

內存緩存

很簡單,直接封裝android.support.v4.util.LruCache類, 外加上過期時間判斷即可

public class MemoryCache {
  private final LruCache<String, Entry> cache;
  private final List<String> keys = new ArrayList<>();

  public MemoryCache(int maxSize) {
    this.cache = new LruCache<>(maxSize);
  }

  private void lookupExpired() {
    Completable.fromAction(
        () -> {
          String key;
          for (int i = 0; i < keys.size(); i++) {
            key = keys.get(i);
            Entry value = cache.get(key);
            if (value != null && value.isExpired()) {
              remove(key);
            }
          }
        })
        .subscribeOn(Schedulers.single())
        .subscribe();
  }

  @CheckForNull
  public synchronized Entry get(String key) {
    Entry value = cache.get(key);
    if (value != null && value.isExpired()) {
      remove(key);
      lookupExpired();
      return null;
    }
    lookupExpired();
    return value;
  }

  public synchronized Entry put(String key, Entry value) {
    if (!keys.contains(key)) {
      keys.add(key);
    }
    Entry oldValue = cache.put(key, value);
    lookupExpired();
    return oldValue;
  }

  public Entry remove(String key) {
    keys.remove(key);
    return cache.remove(key);
  }

  public Map<String, Entry> snapshot() {
    return cache.snapshot();
  }

  public void trimToSize(int maxSize) {
    cache.trimToSize(maxSize);
  }

  public int createCount() {
    return cache.createCount();
  }

  public void evictAll() {
    cache.evictAll();
  }

  public int evictionCount() {
    return cache.evictionCount();
  }

  public int hitCount() {
    return cache.hitCount();
  }

  public int maxSize() {
    return cache.maxSize();
  }

  public int missCount() {
    return cache.missCount();
  }

  public int putCount() {
    return cache.putCount();
  }

  public int size() {
    return cache.size();
  }

  @Immutable
  public static final class Entry {
    @SerializedName("data")
    public final Object data;
    @SerializedName("ttl")
    public final long ttl;
  }
}

硬盤緩存

同樣很簡單,直接參照Okhttp的Cache類邏輯, 直接封裝DiskLruCache類, 外加上過期時間判斷即可.

public final class DiskCache implements Closeable, Flushable {

  /**
   * Unlike {@link okhttp3.Cache} ENTRY_COUNT = 2
   * We don't save the CacheHeader and Respond in two separate files
   * Instead, we wrap them in {@link Entry}
   */
  private static final int ENTRY_COUNT = 1;
  private static final int VERSION = 201105;
  private static final int ENTRY_METADATA = 0;
  private final DiskLruCache cache;

  public DiskCache(File directory, long maxSize) {
    cache = DiskLruCache.create(FileSystem.SYSTEM, directory, VERSION, ENTRY_COUNT, maxSize);
  }

  public Entry get(String key) {
    DiskLruCache.Snapshot snapshot;
    try {
      snapshot = cache.get(key);
      if (snapshot == null) {
        return null;
      }
    } catch (IOException e) {
      return null;
    }
    try {
      BufferedSource source = Okio.buffer(snapshot.getSource(0));
      String json = source.readUtf8();
      source.close();
      Util.closeQuietly(snapshot);
      return DataLayerUtil.fromJson(json, null, Entry.class);

    } catch (IOException e) {
      Util.closeQuietly(snapshot);
      return null;
    }
  }

  public void put(String key, Entry entry) {
    DiskLruCache.Editor editor = null;
    try {
      editor = cache.edit(key);
      if (editor != null) {
        BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA));
        sink.writeUtf8(entry.toString());//Entry.toString() is json String
        sink.close();
        editor.commit();
      }
    } catch (IOException e) {
      abortQuietly(editor);
    }
  }

  public void remove(String key) throws IOException {
    cache.remove(key);
  }

  private void abortQuietly(DiskLruCache.Editor editor) {
    try {
      if (editor != null) {
        editor.abort();
      }
    } catch (IOException ignored) {
    }
  }

  public void initialize() throws IOException {
    cache.initialize();
  }

  public void delete() throws IOException {
    cache.delete();
  }

  public void evictAll() throws IOException {
    cache.evictAll();
  }

  public long size() throws IOException {
    return cache.size();
  }

  public long maxSize() {
    return cache.getMaxSize();
  }

  public File directory() {
    return cache.getDirectory();
  }

  public boolean isClosed() {
    return cache.isClosed();
  }

  @Override
  public void flush() throws IOException {
    cache.flush();
  }

  @Override
  public void close() throws IOException {
    cache.close();
  }

  /**
   * Data and metadata for an entry returned by the cache.
   * It's extracted from android Volley library.
   * See {@code https://github.com/google/volley}
   */
  @Immutable
  public static final class Entry {

    /**
     * The data returned from cache.
     * Use {@link com.thepacific.data.common.DataLayerUtil#toJsonByteArray(Object, Gson)}
     * to serialize a data object
     */
    @SerializedName("data")
    public final byte[] data;

    /**
     * Time to live(TTL) for this record
     */
    @SerializedName("ttl")
    public final long ttl;

    /**
     * Soft TTL for this record
     */
    @SerializedName("softTtl")
    public final long softTtl;

    /**
     * @return To a json String
     */
    @Override
    public String toString() {
      StringBuilder builder = new StringBuilder();
      builder.append("{")
          .append("data=")
          .append(Arrays.toString(data))
          .append(", ttl=")
          .append(ttl)
          .append(", softTtl=")
          .append(softTtl)
          .append("}");
      return builder.toString();
    }

    /**
     * True if the entry is expired.
     */
    public boolean isExpired() {
      return this.ttl < System.currentTimeMillis();
    }

    /**
     * True if a refresh is needed from the original data source.
     */
    public boolean refreshNeeded() {
      return this.softTtl < System.currentTimeMillis();
    }
}

實現Repository

寫一個Repository<T, R>,泛型T代表請求參數類型(如UserQuery),泛型R代表請求結果類型(如User)

/**
 * A repository can get cached data {@link Repository#get(Object)}, or force
 * a call to network(skipping cache) {@link Repository#fetch(Object, boolean)}
 */
public abstract class Repository<T, R> {

  protected final Gson gson;
  protected final DiskCache diskCache;
  protected final MemoryCache memoryCache;
  protected final OnAccessFailure onAccessFailure;
  protected String key;

  public Repository(Gson gson,
      DiskCache diskCache,
      MemoryCache memoryCache,
      OnAccessFailure onAccessFailure) {
    this.gson = gson;
    this.diskCache = diskCache;
    this.memoryCache = memoryCache;
    this.onAccessFailure = onAccessFailure;
  }

  /**
   * Return an Observable of {@link Source <R>} for request query
   * Data will be returned from oldest non expired source
   * Sources are memory cache, disk cache, finally network
   */
  @Nonnull
  public final Observable<Source<R>> get(@Nonnull final T query) {
    ExecutorUtil.requireWorkThread();
    return stream(query)
        .flatMap(it -> {
          if (it.status == Status.SUCCESS) {
            return Observable.just(it);
          }
          return load(query);
        })
        .flatMap(it -> {
          if (it.status == Status.SUCCESS) {
            return Observable.just(it);
          }
          return fetch(query, true);
        });
  }

  /***
   * @param query query parameters
   * @param persist true for persisting data to disk
   * @return an Observable of R for requested query skipping Memory & Disk Cache
   */
  @Nonnull
  public final Observable<Source<R>> fetch(@Nonnull final T query, boolean persist) {
    ExecutorUtil.requireWorkThread();
    Preconditions.checkNotNull(query);
    key = getKey(query);
    return dispatchNetwork().flatMap(it -> {
      if (it.isSuccess()) {
        R newData = it.data();
        if (isIrrelevant(newData)) {
          return Observable.just(Source.irrelevant());
        }
        long ttl = DataLayerUtil.elapsedTimeMillis(ttl());
        long softTtl = DataLayerUtil.elapsedTimeMillis(softTtl());
        long now = System.currentTimeMillis();
        Preconditions.checkState(ttl > now && softTtl > now && ttl >= softTtl);
        if (persist) {
          byte[] bytes = DataLayerUtil.toJsonByteArray(newData, gson);
          diskCache.put(key, DiskCache.Entry.create(bytes, ttl, softTtl));
        } else {
          clearDiskCache();
        }
        memoryCache.put(key, MemoryCache.Entry.create(newData, ttl));
        return Observable.just(Source.success(newData));
      }

      IoError ioError = new IoError(it.message(), it.code());
      if (isAccessFailure(it.code())) {
        diskCache.evictAll();
        memoryCache.evictAll();
        ExecutorUtil.postToMainThread(() -> onAccessFailure.run(ioError));
        return Observable.empty();
      }
      memoryCache.remove(key);
      clearDiskCache();
      return Observable.just(Source.failure(ioError));
    });
  }

  /***
   * @param query query parameters
   * @return an Observable of R for requested from Disk Cache
   */
  @Nonnull
  public final Observable<Source<R>> load(@Nonnull final T query) {
    ExecutorUtil.requireWorkThread();
    Preconditions.checkNotNull(query);
    key = getKey(query);
    return Observable.defer(() -> {
      DiskCache.Entry diskEntry = diskCache.get(key);
      if (diskEntry == null) {
        return Observable.just(Source.irrelevant());
      }
      R newData = gson.fromJson(DataLayerUtil.byteArray2String(diskEntry.data), dataType());
      if (diskEntry.isExpired() || isIrrelevant(newData)) {
        memoryCache.remove(key);
        clearDiskCache();
        return Observable.just(Source.irrelevant());
      }
      memoryCache.put(key, MemoryCache.Entry.create(newData, diskEntry.ttl));
      return Observable.just(Source.success(newData));
    });
  }

  /***
   * @param query query parameters
   * @return an Observable of R for requested from Memory Cache with refreshing query
   * It differs with {@link Repository#stream()}
   */
  @Nonnull
  public final Observable<Source<R>> stream(@Nonnull final T query) {
    Preconditions.checkNotNull(query);
    key = getKey(query);
    return stream();
  }

  /***
   * @return an Observable of R for requested from Memory Cache without refreshing query
   * It differs with {@link Repository#stream(Object)}
   */
  @Nonnull
  public final Observable<Source<R>> stream() {
    return Observable.defer(() -> {
      MemoryCache.Entry memoryEntry = memoryCache.get(key);
      //No need to check isExpired(), MemoryCache.get(key) has already done
      if (memoryEntry == null) {
        return Observable.just(Source.irrelevant());
      }
      R newData = (R) memoryEntry.data;
      if (isIrrelevant(newData)) {
        return Observable.just(Source.irrelevant());
      }
      return Observable.just(Source.success(newData));
    });
  }

  /***
   * @return an R from Memory Cache
   */
  @Nonnull
  public final R memory() {
    MemoryCache.Entry memoryEntry = memoryCache.get(key);
    if (memoryEntry == null) {
      throw new IllegalStateException("Not supported");
    }
    R newData = (R) memoryEntry.data;
    if (isIrrelevant((R) memoryEntry.data)) {
      throw new IllegalStateException("Not supported");
    }
    return newData;
  }

  public final void clearMemoryCache() {
    memoryCache.remove(key);
  }

  public final void clearDiskCache() {
    ExecutorUtil.requireWorkThread();
    try {
      diskCache.remove(key);
    } catch (IOException ignored) {
    }
  }

  /**
   * @return default network cache time is 10. It must be {@code TimeUnit.MINUTES}
   */
  protected int ttl() {
    return 10;
  }

  /**
   * @return default refresh cache time is 5. It must be {@code TimeUnit.MINUTES}
   */
  protected final int softTtl() {
    return 5;
  }

  /**
   * @param code HTTP/HTTPS error code
   * @return some server does't support standard authorize rules
   */
  protected boolean isAccessFailure(final int code) {
    return code == 403 || code == 405;
  }

  /**
   * @return to make sure never returning empty or null data
   */
  protected abstract boolean isIrrelevant(R data);

  /**
   * @return request HTTP/HTTPS API
   */
  protected abstract Observable<Envelope<R>> dispatchNetwork();

  /**
   * @return cache key
   */
  protected abstract String getKey(T query);

  /**
   * @return gson deserialize Class type for R {@code Type typeOfT = R.class} for List<R> {@code
   * Type typeOfT = new TypeToken<List<R>>() { }.getType()}
   */
  protected abstract Type dataType();
}

使用

  @Test
  public void testGet() {
    userRepo.get(userQuery)
        .onErrorReturn(e -> Source.failure(e))
        .startWith(Source.inProgress())
        .subscribe(it -> {
          switch (it.status) {
            case IN_PROGRESS:
              System.out.println("Show Loading Dialog===============");
              break;
            case IRRELEVANT:
              System.out.println("Empty Data===============");
              break;
            case ERROR:
              System.out.println("Error Occur===============");
              break;
            case SUCCESS:
              System.out.println("Update UI===============");
              break;
            default:
              throw new UnsupportedOperationException();
          }
        });
    assertEquals(2, userRepo.memory().size());
  }

源碼

完整源碼請到點擊,并查看data模塊,具體使用請參照單元測試代碼

此外,因為時間原因,現狀態的源碼屬于雛形階段的代碼,代碼多處地方存在不合理或者錯誤. 09月05日前會把生產線上的代碼完整后上傳到github

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,247評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,520評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,362評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,805評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,541評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,896評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,887評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,062評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,608評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,356評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,555評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,077評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,769評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,175評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,489評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,289評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,516評論 2 379

推薦閱讀更多精彩內容