• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 package io.grpc.clientcacheexample;
2 
3 import android.util.Log;
4 import android.util.LruCache;
5 import com.google.common.base.Splitter;
6 import com.google.protobuf.MessageLite;
7 import io.grpc.CallOptions;
8 import io.grpc.Channel;
9 import io.grpc.ClientCall;
10 import io.grpc.ClientInterceptor;
11 import io.grpc.Deadline;
12 import io.grpc.ForwardingClientCall;
13 import io.grpc.ForwardingClientCallListener;
14 import io.grpc.Metadata;
15 import io.grpc.MethodDescriptor;
16 import io.grpc.Status;
17 import java.util.Locale;
18 import java.util.Objects;
19 import java.util.concurrent.TimeUnit;
20 
21 /**
22  * An example of an on-device cache for Android implemented using the {@link ClientInterceptor} API.
23  *
24  * <p>Client-side cache-control directives are not directly supported. Instead, two call options can
25  * be added to the call: no-cache (always go to the network) or only-if-cached (never use network;
26  * if response is not in cache, the request fails).
27  *
28  * <p>This interceptor respects the cache-control directives in the server's response: max-age
29  * determines when the cache entry goes stale. no-cache, no-store, and no-transform entirely skip
30  * caching of the response. must-revalidate is ignored, as the cache does not support returning
31  * stale responses.
32  *
33  * <p>Note: other response headers besides cache-control (such as Expiration, Varies) are ignored by
34  * this implementation.
35  */
36 final class SafeMethodCachingInterceptor implements ClientInterceptor {
37   static CallOptions.Key<Boolean> NO_CACHE_CALL_OPTION = CallOptions.Key.of("no-cache", false);
38   static CallOptions.Key<Boolean> ONLY_IF_CACHED_CALL_OPTION =
39       CallOptions.Key.of("only-if-cached", false);
40   private static final String TAG = "grpcCacheExample";
41 
42   public static final class Key {
43     private final String fullMethodName;
44     private final MessageLite request;
45 
Key(String fullMethodName, MessageLite request)46     public Key(String fullMethodName, MessageLite request) {
47       this.fullMethodName = fullMethodName;
48       this.request = request;
49     }
50 
51     @Override
equals(Object object)52     public boolean equals(Object object) {
53       if (object instanceof Key) {
54         Key other = (Key) object;
55         return Objects.equals(this.fullMethodName, other.fullMethodName)
56             && Objects.equals(this.request, other.request);
57       }
58       return false;
59     }
60 
61     @Override
hashCode()62     public int hashCode() {
63       return Objects.hash(fullMethodName, request);
64     }
65   }
66 
67   public static final class Value {
68     private final MessageLite response;
69     private final Deadline maxAgeDeadline;
70 
Value(MessageLite response, Deadline maxAgeDeadline)71     public Value(MessageLite response, Deadline maxAgeDeadline) {
72       this.response = response;
73       this.maxAgeDeadline = maxAgeDeadline;
74     }
75 
76     @Override
equals(Object object)77     public boolean equals(Object object) {
78       if (object instanceof Value) {
79         Value other = (Value) object;
80         return Objects.equals(this.response, other.response)
81             && Objects.equals(this.maxAgeDeadline, other.maxAgeDeadline);
82       }
83       return false;
84     }
85 
86     @Override
hashCode()87     public int hashCode() {
88       return Objects.hash(response, maxAgeDeadline);
89     }
90   }
91 
92   public interface Cache {
put(Key key, Value value)93     void put(Key key, Value value);
94 
get(Key key)95     Value get(Key key);
96 
remove(Key key)97     void remove(Key key);
98 
clear()99     void clear();
100   }
101 
102   /**
103    * Obtain a new cache with a least-recently used eviction policy and the specified size limit. The
104    * backing caching implementation is provided by {@link LruCache}. It is safe for a single cache
105    * to be shared across multiple {@link SafeMethodCachingInterceptor}s without synchronization.
106    */
newLruCache(final int cacheSizeInBytes)107   public static Cache newLruCache(final int cacheSizeInBytes) {
108     return new Cache() {
109       private final LruCache<Key, Value> lruCache =
110           new LruCache<Key, Value>(cacheSizeInBytes) {
111             protected int sizeOf(Key key, Value value) {
112               return value.response.getSerializedSize();
113             }
114           };
115 
116       @Override
117       public void put(Key key, Value value) {
118         lruCache.put(key, value);
119       }
120 
121       @Override
122       public Value get(Key key) {
123         return lruCache.get(key);
124       }
125 
126       @Override
127       public void remove(Key key) {
128         lruCache.remove(key);
129       }
130 
131       @Override
132       public void clear() {
133         lruCache.evictAll();
134       }
135     };
136   }
137 
138   public static SafeMethodCachingInterceptor newSafeMethodCachingInterceptor(Cache cache) {
139     return newSafeMethodCachingInterceptor(cache, DEFAULT_MAX_AGE_SECONDS);
140   }
141 
142   public static SafeMethodCachingInterceptor newSafeMethodCachingInterceptor(
143       Cache cache, int defaultMaxAge) {
144     return new SafeMethodCachingInterceptor(cache, defaultMaxAge);
145   }
146 
147   private static int DEFAULT_MAX_AGE_SECONDS = 3600;
148 
149   private static final Metadata.Key<String> CACHE_CONTROL_KEY =
150       Metadata.Key.of("cache-control", Metadata.ASCII_STRING_MARSHALLER);
151 
152   private static final Splitter CACHE_CONTROL_SPLITTER =
153       Splitter.on(',').trimResults().omitEmptyStrings();
154 
155   private final Cache internalCache;
156   private final int defaultMaxAge;
157 
158   private SafeMethodCachingInterceptor(Cache cache, int defaultMaxAge) {
159     this.internalCache = cache;
160     this.defaultMaxAge = defaultMaxAge;
161   }
162 
163   @Override
164   public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
165       final MethodDescriptor<ReqT, RespT> method, final CallOptions callOptions, Channel next) {
166     // Currently only unary methods can be marked safe, but check anyways.
167     if (!method.isSafe() || method.getType() != MethodDescriptor.MethodType.UNARY) {
168       return next.newCall(method, callOptions);
169     }
170 
171     final String fullMethodName = method.getFullMethodName();
172 
173     return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
174         next.newCall(method, callOptions)) {
175       private Listener<RespT> interceptedListener;
176       private Key requestKey;
177       private boolean cacheResponse = true;
178       private volatile String cacheOptionsErrorMsg;
179 
180       @Override
181       public void start(Listener<RespT> responseListener, Metadata headers) {
182         interceptedListener =
183             new ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(
184                 responseListener) {
185               private Deadline deadline;
186               private int maxAge = -1;
187 
188               @Override
189               public void onHeaders(Metadata headers) {
190                 Iterable<String> cacheControlHeaders = headers.getAll(CACHE_CONTROL_KEY);
191                 if (cacheResponse && cacheControlHeaders != null) {
192                   for (String cacheControlHeader : cacheControlHeaders) {
193                     for (String directive : CACHE_CONTROL_SPLITTER.split(cacheControlHeader)) {
194                       if (directive.equalsIgnoreCase("no-cache")) {
195                         cacheResponse = false;
196                         break;
197                       } else if (directive.equalsIgnoreCase("no-store")) {
198                         cacheResponse = false;
199                         break;
200                       } else if (directive.equalsIgnoreCase("no-transform")) {
201                         cacheResponse = false;
202                         break;
203                       } else if (directive.toLowerCase(Locale.US).startsWith("max-age")) {
204                         String[] parts = directive.split("=");
205                         if (parts.length == 2) {
206                           try {
207                             maxAge = Integer.parseInt(parts[1]);
208                           } catch (NumberFormatException e) {
209                             Log.e(TAG, "max-age directive failed to parse", e);
210                             continue;
211                           }
212                         }
213                       }
214                     }
215                   }
216                 }
217                 if (cacheResponse) {
218                   if (maxAge > -1) {
219                     deadline = Deadline.after(maxAge, TimeUnit.SECONDS);
220                   } else {
221                     deadline = Deadline.after(defaultMaxAge, TimeUnit.SECONDS);
222                   }
223                 }
224                 super.onHeaders(headers);
225               }
226 
227               @Override
228               public void onMessage(RespT message) {
229                 if (cacheResponse && !deadline.isExpired()) {
230                   Value value = new Value((MessageLite) message, deadline);
231                   internalCache.put(requestKey, value);
232                 }
233                 super.onMessage(message);
234               }
235 
236               @Override
237               public void onClose(Status status, Metadata trailers) {
238                 if (cacheOptionsErrorMsg != null) {
239                   // UNAVAILABLE is the canonical gRPC mapping for HTTP response code 504 (as used
240                   // by the built-in Android HTTP request cache).
241                   super.onClose(
242                       Status.UNAVAILABLE.withDescription(cacheOptionsErrorMsg), new Metadata());
243                 } else {
244                   super.onClose(status, trailers);
245                 }
246               }
247             };
248         delegate().start(interceptedListener, headers);
249       }
250 
251       @Override
252       public void sendMessage(ReqT message) {
253         boolean noCache = callOptions.getOption(NO_CACHE_CALL_OPTION);
254         boolean onlyIfCached = callOptions.getOption(ONLY_IF_CACHED_CALL_OPTION);
255 
256         if (noCache) {
257           if (onlyIfCached) {
258             cacheOptionsErrorMsg = "Unsatisfiable Request (no-cache and only-if-cached conflict)";
259             super.cancel(cacheOptionsErrorMsg, null);
260             return;
261           }
262           cacheResponse = false;
263           super.sendMessage(message);
264           return;
265         }
266 
267         // Check the cache
268         requestKey = new Key(fullMethodName, (MessageLite) message);
269         Value cachedResponse = internalCache.get(requestKey);
270         if (cachedResponse != null) {
271           if (cachedResponse.maxAgeDeadline.isExpired()) {
272             internalCache.remove(requestKey);
273           } else {
274             cacheResponse = false; // already cached
275             interceptedListener.onMessage((RespT) cachedResponse.response);
276             Metadata metadata = new Metadata();
277             interceptedListener.onClose(Status.OK, metadata);
278             return;
279           }
280         }
281 
282         if (onlyIfCached) {
283           cacheOptionsErrorMsg =
284               "Unsatisfiable Request (only-if-cached set, but value not in cache)";
285           super.cancel(cacheOptionsErrorMsg, null);
286           return;
287         }
288         super.sendMessage(message);
289       }
290 
291       @Override
292       public void halfClose() {
293         if (cacheOptionsErrorMsg != null) {
294           // already canceled
295           return;
296         }
297         super.halfClose();
298       }
299     };
300   }
301 }
302