1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using System.Web.Http.Filters; 6 using System.Net.Http.Headers; 7 using System.Net.Http; 8 using System.Threading; 9 using System.Net.Http.Formatting; 10 using System.Web.Http.Controllers; 11 using System.Linq.Expressions; 12 using System.Reflection; 13 using System.Threading.Tasks; 14 using System.Collections; 15 using System.Net; 16 using System.Runtime.Caching; 17 using System.Web.Http; 18 19 20 21 namespace Cashlibary 22 { 23 public class CacheOutputAttribute : ActionFilterAttribute 24 { 25 private const string CurrentRequestMediaType = "CacheOutput:CurrentRequestMediaType"; 26 protected static MediaTypeHeaderValue DefaultMediaType = new MediaTypeHeaderValue("application/json") { CharSet = Encoding.UTF8.HeaderName }; 27 28 /// <summary> 29 /// Cache enabled only for requests when Thread.CurrentPrincipal is not set 30 /// </summary> 31 public bool AnonymousOnly { get; set; } 32 33 /// <summary> 34 /// Corresponds to MustRevalidate HTTP header - indicates whether the origin server requires revalidation of a cache entry on any subsequent use when the cache entry becomes stale 35 /// </summary> 36 public bool MustRevalidate { get; set; } 37 38 /// <summary> 39 /// Do not vary cache by querystring values 40 /// </summary> 41 public bool ExcludeQueryStringFromCacheKey { get; set; } 42 43 /// <summary> 44 /// How long response should be cached on the server side (in seconds) 45 /// </summary> 46 public int ServerTimeSpan { get; set; } 47 48 /// <summary> 49 /// Corresponds to CacheControl MaxAge HTTP header (in seconds) 50 /// </summary> 51 public int ClientTimeSpan { get; set; } 52 53 /// <summary> 54 /// Corresponds to CacheControl NoCache HTTP header 55 /// </summary> 56 public bool NoCache { get; set; } 57 58 /// <summary> 59 /// Corresponds to CacheControl Private HTTP header. Response can be cached by browser but not by intermediary cache 60 /// </summary> 61 public bool Private { get; set; } 62 63 /// <summary> 64 /// Class used to generate caching keys 65 /// </summary> 66 public Type CacheKeyGenerator { get; set; } 67 68 // cache repository 69 private IApiOutputCache _webApiCache; 70 71 protected virtual void EnsureCache(HttpConfiguration config, HttpRequestMessage req) 72 { 73 _webApiCache = config.CacheOutputConfiguration().GetCacheOutputProvider(req); 74 } 75 76 internal IModelQuery<DateTime, CacheTime> CacheTimeQuery; 77 78 protected virtual bool IsCachingAllowed(HttpActionContext actionContext, bool anonymousOnly) 79 { 80 if (anonymousOnly) 81 { 82 if (Thread.CurrentPrincipal.Identity.IsAuthenticated) 83 { 84 return false; 85 } 86 } 87 88 if (actionContext.ActionDescriptor.GetCustomAttributes<IgnoreCacheOutputAttribute>().Any()) 89 { 90 return false; 91 } 92 93 return actionContext.Request.Method == HttpMethod.Post; 94 } 95 96 protected virtual void EnsureCacheTimeQuery() 97 { 98 if (CacheTimeQuery == null) ResetCacheTimeQuery(); 99 } 100 101 protected void ResetCacheTimeQuery() 102 { 103 CacheTimeQuery = new ShortTime(ServerTimeSpan, ClientTimeSpan); 104 } 105 106 protected virtual MediaTypeHeaderValue GetExpectedMediaType(HttpConfiguration config, HttpActionContext actionContext) 107 { 108 MediaTypeHeaderValue responseMediaType = null; 109 110 var negotiator = config.Services.GetService(typeof(IContentNegotiator)) as IContentNegotiator; 111 var returnType = actionContext.ActionDescriptor.ReturnType; 112 113 if (negotiator != null && returnType != typeof(HttpResponseMessage) && (returnType != typeof(IHttpActionResult) || typeof(IHttpActionResult).IsAssignableFrom(returnType))) 114 { 115 var negotiatedResult = negotiator.Negotiate(returnType, actionContext.Request, config.Formatters); 116 117 if (negotiatedResult == null) 118 { 119 return DefaultMediaType; 120 } 121 122 responseMediaType = negotiatedResult.MediaType; 123 if (string.IsNullOrWhiteSpace(responseMediaType.CharSet)) 124 { 125 responseMediaType.CharSet = Encoding.UTF8.HeaderName; 126 } 127 } 128 else 129 { 130 if (actionContext.Request.Headers.Accept != null) 131 { 132 responseMediaType = actionContext.Request.Headers.Accept.FirstOrDefault(); 133 if (responseMediaType == null || !config.Formatters.Any(x => x.SupportedMediaTypes.Contains(responseMediaType))) 134 { 135 return DefaultMediaType; 136 } 137 } 138 } 139 140 return responseMediaType; 141 } 142 143 public override void OnActionExecuting(HttpActionContext actionContext) 144 { 145 if (actionContext == null) throw new ArgumentNullException("actionContext"); 146 147 if (!IsCachingAllowed(actionContext, AnonymousOnly)) return; 148 149 var config = actionContext.Request.GetConfiguration(); 150 151 EnsureCacheTimeQuery(); 152 EnsureCache(config, actionContext.Request); 153 154 var cacheKeyGenerator = config.CacheOutputConfiguration().GetCacheKeyGenerator(actionContext.Request, CacheKeyGenerator); 155 156 var responseMediaType = GetExpectedMediaType(config, actionContext); 157 actionContext.Request.Properties[CurrentRequestMediaType] = responseMediaType; 158 var cachekey = cacheKeyGenerator.MakeCacheKey(actionContext, responseMediaType, ExcludeQueryStringFromCacheKey); 159 160 if (!_webApiCache.Contains(cachekey)) return; 161 162 if (actionContext.Request.Headers.IfNoneMatch != null) 163 { 164 var etag = _webApiCache.Get<string>(cachekey + Constants.EtagKey); 165 if (etag != null) 166 { 167 if (actionContext.Request.Headers.IfNoneMatch.Any(x => x.Tag == etag)) 168 { 169 var time = CacheTimeQuery.Execute(DateTime.Now); 170 var quickResponse = actionContext.Request.CreateResponse(HttpStatusCode.NotModified); 171 ApplyCacheHeaders(quickResponse, time); 172 actionContext.Response = quickResponse; 173 return; 174 } 175 } 176 } 177 178 var val = _webApiCache.Get<byte[]>(cachekey); 179 if (val == null) return; 180 181 var contenttype = _webApiCache.Get<MediaTypeHeaderValue>(cachekey + Constants.ContentTypeKey) ?? new MediaTypeHeaderValue(cachekey.Split(new[] { ':' }, 2)[1].Split(';')[0]); 182 183 actionContext.Response = actionContext.Request.CreateResponse(); 184 actionContext.Response.Content = new ByteArrayContent(val); 185 186 actionContext.Response.Content.Headers.ContentType = contenttype; 187 var responseEtag = _webApiCache.Get<string>(cachekey + Constants.EtagKey); 188 if (responseEtag != null) SetEtag(actionContext.Response, responseEtag); 189 190 var cacheTime = CacheTimeQuery.Execute(DateTime.Now); 191 ApplyCacheHeaders(actionContext.Response, cacheTime); 192 } 193 194 public override async Task OnActionExecutedAsync(HttpActionExecutedContext actionExecutedContext, CancellationToken cancellationToken) 195 { 196 if (actionExecutedContext.ActionContext.Response == null || !actionExecutedContext.ActionContext.Response.IsSuccessStatusCode) return; 197 198 if (!IsCachingAllowed(actionExecutedContext.ActionContext, AnonymousOnly)) return; 199 200 var cacheTime = CacheTimeQuery.Execute(DateTime.Now); 201 if (cacheTime.AbsoluteExpiration > DateTime.Now) 202 { 203 var httpConfig = actionExecutedContext.Request.GetConfiguration(); 204 var config = httpConfig.CacheOutputConfiguration(); 205 var cacheKeyGenerator = config.GetCacheKeyGenerator(actionExecutedContext.Request, CacheKeyGenerator); 206 207 var responseMediaType = actionExecutedContext.Request.Properties[CurrentRequestMediaType] as MediaTypeHeaderValue ?? GetExpectedMediaType(httpConfig, actionExecutedContext.ActionContext); 208 var cachekey = cacheKeyGenerator.MakeCacheKey(actionExecutedContext.ActionContext, responseMediaType, ExcludeQueryStringFromCacheKey); 209 210 if (!string.IsNullOrWhiteSpace(cachekey) && !(_webApiCache.Contains(cachekey))) 211 { 212 SetEtag(actionExecutedContext.Response, CreateEtag(actionExecutedContext, cachekey, cacheTime)); 213 214 var responseContent = actionExecutedContext.Response.Content; 215 216 if (responseContent != null) 217 { 218 var baseKey = config.MakeBaseCachekey(actionExecutedContext.ActionContext.ControllerContext.ControllerDescriptor.ControllerType.FullName, actionExecutedContext.ActionContext.ActionDescriptor.ActionName); 219 var contentType = responseContent.Headers.ContentType; 220 string etag = actionExecutedContext.Response.Headers.ETag.Tag; 221 //ConfigureAwait false to avoid deadlocks 222 var content = await responseContent.ReadAsByteArrayAsync().ConfigureAwait(false); 223 224 responseContent.Headers.Remove("Content-Length"); 225 226 _webApiCache.Add(baseKey, string.Empty, cacheTime.AbsoluteExpiration); 227 _webApiCache.Add(cachekey, content, cacheTime.AbsoluteExpiration, baseKey); 228 229 230 _webApiCache.Add(cachekey + Constants.ContentTypeKey, 231 contentType, 232 cacheTime.AbsoluteExpiration, baseKey); 233 234 235 _webApiCache.Add(cachekey + Constants.EtagKey, 236 etag, 237 cacheTime.AbsoluteExpiration, baseKey); 238 } 239 } 240 } 241 242 ApplyCacheHeaders(actionExecutedContext.ActionContext.Response, cacheTime); 243 } 244 245 protected virtual void ApplyCacheHeaders(HttpResponseMessage response, CacheTime cacheTime) 246 { 247 if (cacheTime.ClientTimeSpan > TimeSpan.Zero || MustRevalidate || Private) 248 { 249 var cachecontrol = new CacheControlHeaderValue 250 { 251 MaxAge = cacheTime.ClientTimeSpan, 252 MustRevalidate = MustRevalidate, 253 Private = Private 254 }; 255 256 response.Headers.CacheControl = cachecontrol; 257 } 258 else if (NoCache) 259 { 260 response.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true }; 261 response.Headers.Add("Pragma", "no-cache"); 262 } 263 } 264 265 protected virtual string CreateEtag(HttpActionExecutedContext actionExecutedContext, string cachekey, CacheTime cacheTime) 266 { 267 return Guid.NewGuid().ToString(); 268 } 269 270 private static void SetEtag(HttpResponseMessage message, string etag) 271 { 272 if (etag != null) 273 { 274 var eTag = new EntityTagHeaderValue(@"""" + etag.Replace(""", string.Empty) + @""""); 275 message.Headers.ETag = eTag; 276 } 277 } 278 } 279 280 public interface ICacheKeyGenerator 281 { 282 string MakeCacheKey(HttpActionContext context, MediaTypeHeaderValue mediaType, bool excludeQueryString = false); 283 } 284 public sealed class Constants 285 { 286 public const string ContentTypeKey = ":response-ct"; 287 public const string EtagKey = ":response-etag"; 288 } 289 290 public class DefaultCacheKeyGenerator : ICacheKeyGenerator 291 { 292 public virtual string MakeCacheKey(HttpActionContext context, MediaTypeHeaderValue mediaType, bool excludeQueryString = false) 293 { 294 var controller = context.ControllerContext.ControllerDescriptor.ControllerType.FullName; 295 var action = context.ActionDescriptor.ActionName; 296 var key = context.Request.GetConfiguration().CacheOutputConfiguration().MakeBaseCachekey(controller, action); 297 var actionParameters = context.ActionArguments.Where(x => x.Value != null).Select(x => x.Key + "=" + GetValue(x.Value)); 298 299 string parameters; 300 301 if (!excludeQueryString) 302 { 303 var queryStringParameters = 304 context.Request.GetQueryNameValuePairs() 305 .Where(x => x.Key.ToLower() != "callback") 306 .Select(x => x.Key + "=" + x.Value); 307 var parametersCollections = actionParameters.Union(queryStringParameters); 308 parameters = "-" + string.Join("&", parametersCollections); 309 310 var callbackValue = GetJsonpCallback(context.Request); 311 if (!string.IsNullOrWhiteSpace(callbackValue)) 312 { 313 var callback = "callback=" + callbackValue; 314 if (parameters.Contains("&" + callback)) parameters = parameters.Replace("&" + callback, string.Empty); 315 if (parameters.Contains(callback + "&")) parameters = parameters.Replace(callback + "&", string.Empty); 316 if (parameters.Contains("-" + callback)) parameters = parameters.Replace("-" + callback, string.Empty); 317 if (parameters.EndsWith("&")) parameters = parameters.TrimEnd('&'); 318 } 319 } 320 else 321 { 322 parameters = "-" + string.Join("&", actionParameters); 323 } 324 325 if (parameters == "-") parameters = string.Empty; 326 327 var cachekey = string.Format("{0}{1}:{2}", key, parameters, mediaType); 328 return cachekey; 329 } 330 331 private string GetJsonpCallback(HttpRequestMessage request) 332 { 333 var callback = string.Empty; 334 if (request.Method == HttpMethod.Get) 335 { 336 var query = request.GetQueryNameValuePairs(); 337 338 if (query != null) 339 { 340 var queryVal = query.FirstOrDefault(x => x.Key.ToLower() == "callback"); 341 if (!queryVal.Equals(default(KeyValuePair<string, string>))) callback = queryVal.Value; 342 } 343 } 344 return callback; 345 } 346 347 private string GetValue(object val) 348 { 349 if (val is IEnumerable && !(val is string)) 350 { 351 var concatValue = string.Empty; 352 var paramArray = val as IEnumerable; 353 return paramArray.Cast<object>().Aggregate(concatValue, (current, paramValue) => current + (paramValue + ";")); 354 } 355 return val.ToString(); 356 } 357 } 358 359 360 public class CacheOutputConfiguration 361 { 362 private readonly HttpConfiguration _configuration; 363 364 public CacheOutputConfiguration(HttpConfiguration configuration) 365 { 366 _configuration = configuration; 367 } 368 369 public void RegisterCacheOutputProvider(Func<IApiOutputCache> provider) 370 { 371 _configuration.Properties.GetOrAdd(typeof(IApiOutputCache), x => provider); 372 } 373 374 public void RegisterCacheKeyGeneratorProvider<T>(Func<T> provider) 375 where T : ICacheKeyGenerator 376 { 377 _configuration.Properties.GetOrAdd(typeof(T), x => provider); 378 } 379 380 public void RegisterDefaultCacheKeyGeneratorProvider(Func<ICacheKeyGenerator> provider) 381 { 382 RegisterCacheKeyGeneratorProvider(provider); 383 } 384 385 public string MakeBaseCachekey(string controller, string action) 386 { 387 return string.Format("{0}-{1}", controller.ToLower(), action.ToLower()); 388 } 389 390 public string MakeBaseCachekey<T, U>(Expression<Func<T, U>> expression) 391 { 392 var method = expression.Body as MethodCallExpression; 393 if (method == null) throw new ArgumentException("Expression is wrong"); 394 395 var methodName = method.Method.Name; 396 var nameAttribs = method.Method.GetCustomAttributes(typeof(ActionNameAttribute), false); 397 if (nameAttribs.Any()) 398 { 399 var actionNameAttrib = (ActionNameAttribute)nameAttribs.FirstOrDefault(); 400 if (actionNameAttrib != null) 401 { 402 methodName = actionNameAttrib.Name; 403 } 404 } 405 406 return string.Format("{0}-{1}", typeof(T).FullName.ToLower(), methodName.ToLower()); 407 } 408 409 private static ICacheKeyGenerator TryActivateCacheKeyGenerator(Type generatorType) 410 { 411 var hasEmptyOrDefaultConstructor = 412 generatorType.GetConstructor(Type.EmptyTypes) != null || 413 generatorType.GetConstructors(BindingFlags.Instance | BindingFlags.Public) 414 .Any(x => x.GetParameters().All(p => p.IsOptional)); 415 return hasEmptyOrDefaultConstructor 416 ? Activator.CreateInstance(generatorType) as ICacheKeyGenerator 417 : null; 418 } 419 420 public ICacheKeyGenerator GetCacheKeyGenerator(HttpRequestMessage request, Type generatorType) 421 { 422 generatorType = generatorType ?? typeof(ICacheKeyGenerator); 423 object cache; 424 _configuration.Properties.TryGetValue(generatorType, out cache); 425 426 var cacheFunc = cache as Func<ICacheKeyGenerator>; 427 428 var generator = cacheFunc != null 429 ? cacheFunc() 430 : request.GetDependencyScope().GetService(generatorType) as ICacheKeyGenerator; 431 432 return generator 433 ?? TryActivateCacheKeyGenerator(generatorType) 434 ?? new DefaultCacheKeyGenerator(); 435 } 436 437 public IApiOutputCache GetCacheOutputProvider(HttpRequestMessage request) 438 { 439 object cache; 440 _configuration.Properties.TryGetValue(typeof(IApiOutputCache), out cache); 441 442 var cacheFunc = cache as Func<IApiOutputCache>; 443 444 var cacheOutputProvider = cacheFunc != null ? cacheFunc() : request.GetDependencyScope().GetService(typeof(IApiOutputCache)) as IApiOutputCache ?? new MemoryCacheDefault(); 445 return cacheOutputProvider; 446 } 447 } 448 449 public static class HttpConfigurationExtensions 450 { 451 public static CacheOutputConfiguration CacheOutputConfiguration(this HttpConfiguration config) 452 { 453 return new CacheOutputConfiguration(config); 454 } 455 } 456 public sealed class IgnoreCacheOutputAttribute : Attribute 457 { 458 } 459 460 public class CacheTime 461 { 462 // client cache length in seconds 463 public TimeSpan ClientTimeSpan { get; set; } 464 465 public DateTimeOffset AbsoluteExpiration { get; set; } 466 } 467 public interface IApiOutputCache 468 { 469 void RemoveStartsWith(string key); 470 471 T Get<T>(string key) where T : class; 472 473 [Obsolete("Use Get<T> instead")] 474 object Get(string key); 475 476 void Remove(string key); 477 478 bool Contains(string key); 479 480 void Add(string key, object o, DateTimeOffset expiration, string dependsOnKey = null); 481 482 IEnumerable<string> AllKeys { get; } 483 } 484 485 public class ShortTime : IModelQuery<DateTime, CacheTime> 486 { 487 private readonly int serverTimeInSeconds; 488 private readonly int clientTimeInSeconds; 489 490 public ShortTime(int serverTimeInSeconds, int clientTimeInSeconds) 491 { 492 if (serverTimeInSeconds < 0) 493 serverTimeInSeconds = 0; 494 495 this.serverTimeInSeconds = serverTimeInSeconds; 496 497 if (clientTimeInSeconds < 0) 498 clientTimeInSeconds = 0; 499 500 this.clientTimeInSeconds = clientTimeInSeconds; 501 } 502 503 public CacheTime Execute(DateTime model) 504 { 505 var cacheTime = new CacheTime 506 { 507 AbsoluteExpiration = model.AddSeconds(serverTimeInSeconds), 508 ClientTimeSpan = TimeSpan.FromSeconds(clientTimeInSeconds) 509 }; 510 511 return cacheTime; 512 } 513 } 514 public interface IModelQuery<in TModel, out TResult> 515 { 516 TResult Execute(TModel model); 517 } 518 public class MemoryCacheDefault : IApiOutputCache 519 { 520 private static readonly MemoryCache Cache = MemoryCache.Default; 521 522 public void RemoveStartsWith(string key) 523 { 524 lock (Cache) 525 { 526 Cache.Remove(key); 527 } 528 } 529 530 public T Get<T>(string key) where T : class 531 { 532 var o = Cache.Get(key) as T; 533 return o; 534 } 535 536 [Obsolete("Use Get<T> instead")] 537 public object Get(string key) 538 { 539 return Cache.Get(key); 540 } 541 542 public void Remove(string key) 543 { 544 lock (Cache) 545 { 546 Cache.Remove(key); 547 } 548 } 549 550 public bool Contains(string key) 551 { 552 return Cache.Contains(key); 553 } 554 555 public void Add(string key, object o, DateTimeOffset expiration, string dependsOnKey = null) 556 { 557 var cachePolicy = new CacheItemPolicy 558 { 559 AbsoluteExpiration = expiration 560 }; 561 562 if (!string.IsNullOrWhiteSpace(dependsOnKey)) 563 { 564 cachePolicy.ChangeMonitors.Add( 565 Cache.CreateCacheEntryChangeMonitor(new[] { dependsOnKey }) 566 ); 567 } 568 lock (Cache) 569 { 570 Cache.Add(key, o, cachePolicy); 571 } 572 } 573 574 public IEnumerable<string> AllKeys 575 { 576 get 577 { 578 return Cache.Select(x => x.Key); 579 } 580 } 581 } 582 583 // 使用方法 [CacheOutput(ClientTimeSpan = 350, ServerTimeSpan = 50)] 584 585 586 587 }