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