Skip to content

Commit

Permalink
Add OutputCachePolicy support (#2328)
Browse files Browse the repository at this point in the history
  • Loading branch information
witskeeper authored Nov 28, 2023
1 parent c664310 commit 3853af1
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 6 deletions.
63 changes: 63 additions & 0 deletions docs/docfx/articles/output-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Output Caching

## Introduction
The reverse proxy can be used to cache proxied responses and serve requests before they are proxied to the destination servers. This can reduce load on the destination servers, add a layer of protection, and ensure consistent policies are implemented across your applications.

> This feature is only available when using .NET 7.0 or later
## Defaults

No output caching is performed unless enabled in the route or application configuration.

## Configuration
Output Cache policies can be specified per route via [RouteConfig.OutputCachePolicy](xref:Yarp.ReverseProxy.Configuration.RouteConfig) and can be bound from the `Routes` sections of the config file. As with other route properties, this can be modified and reloaded without restarting the proxy. Policy names are case insensitive.

Example:
```JSON
{
"ReverseProxy": {
"Routes": {
"route1" : {
"ClusterId": "cluster1",
"OutputCachePolicy": "customPolicy",
"Match": {
"Hosts": [ "localhost" ]
}
}
},
"Clusters": {
"cluster1": {
"Destinations": {
"cluster1/destination1": {
"Address": "https://localhost:10001/"
}
}
}
}
}
}
```

[Output cache policies](https://learn.microsoft.com/aspnet/core/performance/caching/output) are an ASP.NET Core concept that the proxy utilizes. The proxy provides the above configuration to specify a policy per route and the rest is handled by existing ASP.NET Core output caching middleware.

Output cache policies can be configured in Program.cs as follows:
```c#
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOutputCache(options =>
{
options.AddPolicy("customPolicy", builder => builder.Expire(TimeSpan.FromSeconds(20)));
});
```

Then add the output caching middleware:

```c#
var app = builder.Build();

app.UseOutputCache();

app.MapReverseProxy();
```

See the [Output Caching](https://learn.microsoft.com/aspnet/core/performance/caching/output) docs for setting up your preferred kind of output caching.
2 changes: 2 additions & 0 deletions docs/docfx/articles/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
href: authn-authz.md
- name: Rate Limiting
href: rate-limiting.md
- name: Output Caching
href: output-caching.md
- name: Cross-Origin Requests (CORS)
href: cors.md
- name: Session Affinity
Expand Down
14 changes: 10 additions & 4 deletions samples/KubernetesIngress.Sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,17 @@ metadata:
annotations:
yarp.ingress.kubernetes.io/authorization-policy: authzpolicy
yarp.ingress.kubernetes.io/rate-limiter-policy: ratelimiterpolicy
yarp.ingress.kubernetes.io/output-cache-policy: outputcachepolicy
yarp.ingress.kubernetes.io/transforms: |
- PathRemovePrefix: "/apis"
yarp.ingress.kubernetes.io/route-headers: |
- Name: the-header-key
Values:
Values:
- the-header-value
Mode: Contains
IsCaseSensitive: false
- Name: another-header-key
Values:
Values:
- another-header-value
Mode: Contains
IsCaseSensitive: false
Expand All @@ -75,6 +76,7 @@ The table below lists the available annotations.
|---|---|
|yarp.ingress.kubernetes.io/authorization-policy|string|
|yarp.ingress.kubernetes.io/rate-limiter-policy|string|
|yarp.ingress.kubernetes.io/output-cache-policy|string|
|yarp.ingress.kubernetes.io/backend-protocol|string|
|yarp.ingress.kubernetes.io/cors-policy|string|
|yarp.ingress.kubernetes.io/health-check|[ActivateHealthCheckConfig](https://microsoft.github.io/reverse-proxy/api/Yarp.ReverseProxy.Configuration.ActiveHealthCheckConfig.html)|
Expand All @@ -98,6 +100,10 @@ See https://microsoft.github.io/reverse-proxy/articles/rate-limiting.html for a

`yarp.ingress.kubernetes.io/rate-limiter-policy: mypolicy`

#### Output Cache Policy

`yarp.ingress.kubernetes.io/output-cache-policy: mycachepolicy`

#### Backend Protocol

Specifies the protocol of the backend service. Defaults to http.
Expand Down Expand Up @@ -196,12 +202,12 @@ See https://microsoft.github.io/reverse-proxy/api/Yarp.ReverseProxy.Configuratio
```
yarp.ingress.kubernetes.io/route-headers: |
- Name: the-header-key
Values:
Values:
- the-header-value
Mode: Contains
IsCaseSensitive: false
- Name: another-header-key
Values:
Values:
- another-header-value
Mode: Contains
IsCaseSensitive: false
Expand Down
1 change: 1 addition & 0 deletions src/Kubernetes.Controller/Converters/YarpIngressOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal sealed class YarpIngressOptions
public string AuthorizationPolicy { get; set; }
#if NET7_0_OR_GREATER
public string RateLimiterPolicy { get; set; }
public string OutputCachePolicy { get; set; }
#endif
public SessionAffinityConfig SessionAffinity { get; set; }
public HttpClientConfig HttpClientConfig { get; set; }
Expand Down
15 changes: 15 additions & 0 deletions src/Kubernetes.Controller/Converters/YarpParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ private static RouteConfig CreateRoute(YarpIngressContext ingressContext, V1HTTP
AuthorizationPolicy = ingressContext.Options.AuthorizationPolicy,
#if NET7_0_OR_GREATER
RateLimiterPolicy = ingressContext.Options.RateLimiterPolicy,
OutputCachePolicy = ingressContext.Options.OutputCachePolicy,
#endif
#if NET8_0_OR_GREATER
Timeout = ingressContext.Options.Timeout,
Expand Down Expand Up @@ -234,6 +235,20 @@ private static YarpIngressOptions HandleAnnotations(YarpIngressContext context,
{
options.RateLimiterPolicy = rateLimiterPolicy;
}
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/output-cache-policy", out var outputCachePolicy))
{
options.OutputCachePolicy = outputCachePolicy;
}
#endif
#if NET8_0_OR_GREATER
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/timeout", out var timeout))
{
options.Timeout = TimeSpan.Parse(timeout, CultureInfo.InvariantCulture);
}
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/timeout-policy", out var timeoutPolicy))
{
options.TimeoutPolicy = timeoutPolicy;
}
#endif
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/cors-policy", out var corsPolicy))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ private static RouteConfig CreateRoute(IConfigurationSection section)
AuthorizationPolicy = section[nameof(RouteConfig.AuthorizationPolicy)],
#if NET7_0_OR_GREATER
RateLimiterPolicy = section[nameof(RouteConfig.RateLimiterPolicy)],
OutputCachePolicy = section[nameof(RouteConfig.OutputCachePolicy)],
#endif
#if NET8_0_OR_GREATER
TimeoutPolicy = section[nameof(RouteConfig.TimeoutPolicy)],
Expand Down
55 changes: 55 additions & 0 deletions src/ReverseProxy/Configuration/IYarpOutputCachePolicyProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

#if NET7_0_OR_GREATER
using System;
using System.Collections;
using System.Reflection;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Options;
#endif

using System.Collections.Generic;
using System.Threading.Tasks;

namespace Yarp.ReverseProxy.Configuration;

// TODO: update or remove this once AspNetCore provides a mechanism to validate the OutputCache policies https://github.com/dotnet/aspnetcore/issues/52419

internal interface IYarpOutputCachePolicyProvider
{
ValueTask<object?> GetPolicyAsync(string policyName);
}

internal class YarpOutputCachePolicyProvider : IYarpOutputCachePolicyProvider
{
#if NET7_0_OR_GREATER
private readonly OutputCacheOptions _outputCacheOptions;

private readonly IDictionary _policyMap;

public YarpOutputCachePolicyProvider(IOptions<OutputCacheOptions> outputCacheOptions)
{
_outputCacheOptions = outputCacheOptions?.Value ?? throw new ArgumentNullException(nameof(outputCacheOptions));

var type = typeof(OutputCacheOptions);
var flags = BindingFlags.Instance | BindingFlags.NonPublic;
var proprety = type.GetProperty("NamedPolicies", flags);
if (proprety == null || !typeof(IDictionary).IsAssignableFrom(proprety.PropertyType))
{
throw new NotSupportedException("This version of YARP is incompatible with the current version of ASP.NET Core.");
}
_policyMap = (proprety.GetValue(_outputCacheOptions, null) as IDictionary) ?? new Dictionary<string, object>();
}

public ValueTask<object?> GetPolicyAsync(string policyName)
{
return ValueTask.FromResult(_policyMap[policyName]);
}
#else
public ValueTask<object?> GetPolicyAsync(string policyName)
{
return default;
}
#endif
}
8 changes: 8 additions & 0 deletions src/ReverseProxy/Configuration/RouteConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ public sealed record RouteConfig
/// Set to "Default" or leave empty to use the global rate limits, if any.
/// </summary>
public string? RateLimiterPolicy { get; init; }

/// <summary>
/// The name of the OutputCachePolicy to apply to this route.
/// If not set then only the BasePolicy will apply.
/// </summary>
public string? OutputCachePolicy { get; init; }
#endif
#if NET8_0_OR_GREATER
/// <summary>
Expand Down Expand Up @@ -106,6 +112,7 @@ public bool Equals(RouteConfig? other)
&& string.Equals(AuthorizationPolicy, other.AuthorizationPolicy, StringComparison.OrdinalIgnoreCase)
#if NET7_0_OR_GREATER
&& string.Equals(RateLimiterPolicy, other.RateLimiterPolicy, StringComparison.OrdinalIgnoreCase)
&& string.Equals(OutputCachePolicy, other.OutputCachePolicy, StringComparison.OrdinalIgnoreCase)
#endif
#if NET8_0_OR_GREATER
&& string.Equals(TimeoutPolicy, other.TimeoutPolicy, StringComparison.OrdinalIgnoreCase)
Expand All @@ -127,6 +134,7 @@ public override int GetHashCode()
hash.Add(AuthorizationPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
#if NET7_0_OR_GREATER
hash.Add(RateLimiterPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
hash.Add(OutputCachePolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
#endif
#if NET8_0_OR_GREATER
hash.Add(Timeout?.GetHashCode());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Yarp.ReverseProxy.Configuration.RouteValidators;

internal sealed class OutputCachePolicyValidator : IRouteValidator
{
#if NET7_0_OR_GREATER
private readonly IYarpOutputCachePolicyProvider _outputCachePolicyProvider;
public OutputCachePolicyValidator(IYarpOutputCachePolicyProvider outputCachePolicyProvider)
{
_outputCachePolicyProvider = outputCachePolicyProvider;
}

public async ValueTask ValidateAsync(RouteConfig routeConfig, IList<Exception> errors)
{
var outputCachePolicyName = routeConfig.OutputCachePolicy;

if (string.IsNullOrEmpty(outputCachePolicyName))
{
return;
}

try
{
var policy = await _outputCachePolicyProvider.GetPolicyAsync(outputCachePolicyName);

if (policy is null)
{
errors.Add(new ArgumentException(
$"OutputCache policy '{outputCachePolicyName}' not found for route '{routeConfig.RouteId}'."));
}
}
catch (Exception ex)
{
errors.Add(new ArgumentException(
$"Unable to retrieve the OutputCache policy '{outputCachePolicyName}' for route '{routeConfig.RouteId}'.",
ex));
}
}
#else
public ValueTask ValidateAsync(RouteConfig routeConfig, IList<Exception> errors) => ValueTask.CompletedTask;
#endif
}
8 changes: 6 additions & 2 deletions src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ internal static class IReverseProxyBuilderExtensions
public static IReverseProxyBuilder AddConfigBuilder(this IReverseProxyBuilder builder)
{
builder.Services.TryAddSingleton<IYarpRateLimiterPolicyProvider, YarpRateLimiterPolicyProvider>();
builder.Services.TryAddSingleton<IYarpOutputCachePolicyProvider, YarpOutputCachePolicyProvider>();
builder.Services.TryAddSingleton<IConfigValidator, ConfigValidator>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IRouteValidator, AuthorizationPolicyValidator>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IRouteValidator, RateLimitPolicyValidator>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IRouteValidator, OutputCachePolicyValidator>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IRouteValidator, TimeoutPolicyValidator>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IRouteValidator, CorsPolicyValidator>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IRouteValidator, HeadersValidator>());
Expand Down Expand Up @@ -121,8 +123,10 @@ public static IReverseProxyBuilder AddActiveHealthChecks(this IReverseProxyBuild
if (!builder.Services.Any(d => d.ServiceType == typeof(IActiveHealthCheckMonitor)))
{
builder.Services.AddSingleton<ActiveHealthCheckMonitor>();
builder.Services.AddSingleton<IActiveHealthCheckMonitor>(p => p.GetRequiredService<ActiveHealthCheckMonitor>());
builder.Services.AddSingleton<IClusterChangeListener>(p => p.GetRequiredService<ActiveHealthCheckMonitor>());
builder.Services.AddSingleton<IActiveHealthCheckMonitor>(p =>
p.GetRequiredService<ActiveHealthCheckMonitor>());
builder.Services.AddSingleton<IClusterChangeListener>(p =>
p.GetRequiredService<ActiveHealthCheckMonitor>());
}

builder.Services.AddSingleton<IActiveHealthCheckPolicy, ConsecutiveFailuresHealthPolicy>();
Expand Down
6 changes: 6 additions & 0 deletions src/ReverseProxy/Routing/ProxyEndpointFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#endif
#if NET7_0_OR_GREATER
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.OutputCaching;
#endif
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
Expand Down Expand Up @@ -137,6 +138,11 @@ public Endpoint CreateEndpoint(RouteModel route, IReadOnlyList<Action<EndpointBu
{
endpointBuilder.Metadata.Add(new EnableRateLimitingAttribute(config.RateLimiterPolicy));
}

if (!string.IsNullOrEmpty(config.OutputCachePolicy))
{
endpointBuilder.Metadata.Add(new OutputCacheAttribute { PolicyName = config.OutputCachePolicy });
}
#endif
#if NET8_0_OR_GREATER
if (string.Equals(TimeoutPolicyConstants.Disable, config.TimeoutPolicy, StringComparison.OrdinalIgnoreCase))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ public class ConfigurationConfigProviderTests
""ClusterId"": ""cluster1"",
""AuthorizationPolicy"": ""Default"",
""RateLimiterPolicy"": ""Default"",
""OutputCachePolicy"": ""Default"",
""CorsPolicy"": ""Default"",
""TimeoutPolicy"": ""Default"",
""Timeout"": ""00:00:01"",
Expand Down Expand Up @@ -393,6 +394,7 @@ public class ConfigurationConfigProviderTests
""ClusterId"": ""cluster2"",
""AuthorizationPolicy"": null,
""RateLimiterPolicy"": null,
""OutputCachePolicy"": null,
""CorsPolicy"": null,
""Metadata"": null,
""Transforms"": null
Expand Down
Loading

0 comments on commit 3853af1

Please sign in to comment.