-
-
Notifications
You must be signed in to change notification settings - Fork 9.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for caching template rendering result (#4091)
#### What type of PR is this? /kind feature /area core #### What this PR does / why we need it: This PR adds dependency [spring-boot-starter-cache](https://docs.spring.io/spring-boot/docs/current/reference/html/io.html#io.caching) as cache framework and [caffeine](https://github.com/ben-manes/caffeine/wiki) as cache implementation to cache template rendering result. By default, we disable the cache feature. If you want to enable it, please try to configure properties like this: ```yaml halo: cache: disabled: false ``` #### Which issue(s) this PR fixes: Fixes #2827 #### Special notes for your reviewer: 1. Start Halo 2. Browse any page twice 3. See the difference in request times #### Does this PR introduce a user-facing change? ```release-note 支持模板渲染结果缓存 ```
- Loading branch information
Showing
19 changed files
with
537 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
52 changes: 52 additions & 0 deletions
52
application/src/main/java/run/halo/app/cache/CacheEndpoint.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
package run.halo.app.cache; | ||
|
||
import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; | ||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; | ||
import static org.springframework.http.HttpStatus.NO_CONTENT; | ||
|
||
import org.springdoc.core.fn.builders.apiresponse.Builder; | ||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; | ||
import org.springframework.cache.CacheManager; | ||
import org.springframework.stereotype.Component; | ||
import org.springframework.web.reactive.function.server.RouterFunction; | ||
import org.springframework.web.reactive.function.server.ServerResponse; | ||
import run.halo.app.core.extension.endpoint.CustomEndpoint; | ||
|
||
@Component | ||
public class CacheEndpoint implements CustomEndpoint { | ||
|
||
private final CacheManager cacheManager; | ||
|
||
public CacheEndpoint(CacheManager cacheManager) { | ||
this.cacheManager = cacheManager; | ||
} | ||
|
||
@Override | ||
public RouterFunction<ServerResponse> endpoint() { | ||
return SpringdocRouteBuilder | ||
.route() | ||
.POST("/caches/{name}/invalidation", request -> { | ||
var cacheName = request.pathVariable("name"); | ||
if (cacheManager.getCacheNames().contains(cacheName)) { | ||
var cache = cacheManager.getCache(cacheName); | ||
if (cache != null) { | ||
cache.invalidate(); | ||
} | ||
} | ||
return ServerResponse.noContent().build(); | ||
}, builder -> builder | ||
.tag("v1alpha1/Cache") | ||
.operationId("InvalidCache") | ||
.description("Invalidate a cache.") | ||
.parameter(parameterBuilder() | ||
.name("name") | ||
.in(PATH) | ||
.required(true) | ||
.description("Cache name")) | ||
.response(Builder.responseBuilder() | ||
.responseCode(String.valueOf(NO_CONTENT.value()))) | ||
.build()) | ||
.build(); | ||
} | ||
|
||
} |
158 changes: 158 additions & 0 deletions
158
application/src/main/java/run/halo/app/cache/CacheWebFilter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
package run.halo.app.cache; | ||
|
||
import static java.nio.ByteBuffer.allocateDirect; | ||
import static org.springframework.http.HttpHeaders.CACHE_CONTROL; | ||
import static run.halo.app.infra.AnonymousUserConst.isAnonymousUser; | ||
|
||
import java.time.Instant; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.reactivestreams.Publisher; | ||
import org.springframework.cache.Cache; | ||
import org.springframework.cache.CacheManager; | ||
import org.springframework.core.Ordered; | ||
import org.springframework.core.io.buffer.DataBuffer; | ||
import org.springframework.core.io.buffer.DataBufferUtils; | ||
import org.springframework.http.HttpHeaders; | ||
import org.springframework.http.HttpMethod; | ||
import org.springframework.http.HttpStatus; | ||
import org.springframework.http.MediaType; | ||
import org.springframework.http.server.reactive.ServerHttpRequest; | ||
import org.springframework.http.server.reactive.ServerHttpResponse; | ||
import org.springframework.http.server.reactive.ServerHttpResponseDecorator; | ||
import org.springframework.lang.NonNull; | ||
import org.springframework.security.core.Authentication; | ||
import org.springframework.security.core.context.ReactiveSecurityContextHolder; | ||
import org.springframework.security.core.context.SecurityContext; | ||
import org.springframework.web.server.ServerWebExchange; | ||
import org.springframework.web.server.WebFilter; | ||
import org.springframework.web.server.WebFilterChain; | ||
import reactor.core.publisher.Flux; | ||
import reactor.core.publisher.Mono; | ||
|
||
@Slf4j | ||
public class CacheWebFilter implements WebFilter, Ordered { | ||
|
||
public static final String REQUEST_TO_CACHE = "RequestCacheWebFilterToCache"; | ||
|
||
public static final String CACHE_NAME = "page-cache"; | ||
|
||
private final Cache cache; | ||
|
||
public CacheWebFilter(CacheManager cacheManager) { | ||
this.cache = cacheManager.getCache(CACHE_NAME); | ||
} | ||
|
||
@Override | ||
@NonNull | ||
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { | ||
return ReactiveSecurityContextHolder.getContext() | ||
.map(SecurityContext::getAuthentication) | ||
.map(Authentication::getName) | ||
.filter(name -> isAnonymousUser(name) && requestCacheable(exchange.getRequest())) | ||
.switchIfEmpty(Mono.defer(() -> chain.filter(exchange).then(Mono.empty()))) | ||
.flatMap(name -> { | ||
var cacheKey = generateCacheKey(exchange.getRequest()); | ||
var cachedResponse = cache.get(cacheKey, CachedResponse.class); | ||
if (cachedResponse != null) { | ||
// cache hit, then write the cached response | ||
return writeCachedResponse(exchange.getResponse(), cachedResponse); | ||
} | ||
// decorate the ServerHttpResponse to cache the response | ||
var decoratedExchange = exchange.mutate() | ||
.response(new CacheResponseDecorator(exchange, cacheKey)) | ||
.build(); | ||
return chain.filter(decoratedExchange); | ||
}); | ||
} | ||
|
||
private boolean requestCacheable(ServerHttpRequest request) { | ||
return HttpMethod.GET.equals(request.getMethod()) | ||
&& !hasRequestBody(request) | ||
&& enableCacheByCacheControl(request.getHeaders()); | ||
} | ||
|
||
private boolean enableCacheByCacheControl(HttpHeaders headers) { | ||
return headers.getOrEmpty(CACHE_CONTROL) | ||
.stream() | ||
.noneMatch(cacheControl -> | ||
"no-store".equals(cacheControl) || "private".equals(cacheControl)); | ||
} | ||
|
||
private boolean responseCacheable(ServerWebExchange exchange) { | ||
var response = exchange.getResponse(); | ||
if (!MediaType.TEXT_HTML.equals(response.getHeaders().getContentType())) { | ||
return false; | ||
} | ||
var statusCode = response.getStatusCode(); | ||
if (statusCode == null || !statusCode.isSameCodeAs(HttpStatus.OK)) { | ||
return false; | ||
} | ||
return exchange.getAttributeOrDefault(REQUEST_TO_CACHE, false); | ||
} | ||
|
||
private static boolean hasRequestBody(ServerHttpRequest request) { | ||
return request.getHeaders().getContentLength() > 0; | ||
} | ||
|
||
private String generateCacheKey(ServerHttpRequest request) { | ||
return request.getURI().toASCIIString(); | ||
} | ||
|
||
@Override | ||
public int getOrder() { | ||
// The filter should be after org.springframework.security.web.server.WebFilterChainProxy | ||
return Ordered.LOWEST_PRECEDENCE; | ||
} | ||
|
||
private Mono<Void> writeCachedResponse(ServerHttpResponse response, | ||
CachedResponse cachedResponse) { | ||
response.setStatusCode(cachedResponse.statusCode()); | ||
response.getHeaders().clear(); | ||
response.getHeaders().addAll(cachedResponse.headers()); | ||
var body = Flux.fromIterable(cachedResponse.body()) | ||
.map(byteBuffer -> response.bufferFactory().wrap(byteBuffer)); | ||
return response.writeWith(body); | ||
} | ||
|
||
class CacheResponseDecorator extends ServerHttpResponseDecorator { | ||
|
||
private final ServerWebExchange exchange; | ||
|
||
private final String cacheKey; | ||
|
||
public CacheResponseDecorator(ServerWebExchange exchange, String cacheKey) { | ||
super(exchange.getResponse()); | ||
this.exchange = exchange; | ||
this.cacheKey = cacheKey; | ||
} | ||
|
||
@Override | ||
@NonNull | ||
public Mono<Void> writeWith(@NonNull Publisher<? extends DataBuffer> body) { | ||
if (responseCacheable(exchange)) { | ||
var response = getDelegate(); | ||
body = Flux.from(body) | ||
.map(dataBuffer -> { | ||
var byteBuffer = allocateDirect(dataBuffer.readableByteCount()); | ||
dataBuffer.toByteBuffer(byteBuffer); | ||
DataBufferUtils.release(dataBuffer); | ||
return byteBuffer.asReadOnlyBuffer(); | ||
}) | ||
.collectSortedList() | ||
.doOnSuccess(byteBuffers -> { | ||
var headers = new HttpHeaders(); | ||
headers.addAll(response.getHeaders()); | ||
var cachedResponse = new CachedResponse(response.getStatusCode(), | ||
headers, | ||
byteBuffers, | ||
Instant.now()); | ||
cache.put(cacheKey, cachedResponse); | ||
}) | ||
.flatMapMany(Flux::fromIterable) | ||
.map(byteBuffer -> response.bufferFactory().wrap(byteBuffer)); | ||
} | ||
// write the response | ||
return super.writeWith(body); | ||
} | ||
} | ||
} |
18 changes: 18 additions & 0 deletions
18
application/src/main/java/run/halo/app/cache/CachedResponse.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package run.halo.app.cache; | ||
|
||
import java.nio.ByteBuffer; | ||
import java.time.Instant; | ||
import java.util.List; | ||
import org.springframework.http.HttpHeaders; | ||
import org.springframework.http.HttpStatusCode; | ||
|
||
/** | ||
* Cached response. Refer to | ||
* <a href="https://github.com/spring-cloud/spring-cloud-gateway/blob/f98aa6d47bf802019f07063f4fd7af6047f15116/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java">here</a> } | ||
*/ | ||
public record CachedResponse(HttpStatusCode statusCode, | ||
HttpHeaders headers, | ||
List<ByteBuffer> body, | ||
Instant timestamp) { | ||
|
||
} |
20 changes: 20 additions & 0 deletions
20
application/src/main/java/run/halo/app/config/CacheConfiguration.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
package run.halo.app.config; | ||
|
||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | ||
import org.springframework.cache.CacheManager; | ||
import org.springframework.cache.annotation.EnableCaching; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.web.server.WebFilter; | ||
import run.halo.app.cache.CacheWebFilter; | ||
|
||
@EnableCaching | ||
@Configuration | ||
public class CacheConfiguration { | ||
|
||
@Bean | ||
@ConditionalOnProperty(name = "halo.cache.disabled", havingValue = "false") | ||
WebFilter cacheWebFilter(CacheManager cacheManager) { | ||
return new CacheWebFilter(cacheManager); | ||
} | ||
} |
10 changes: 10 additions & 0 deletions
10
application/src/main/java/run/halo/app/infra/properties/CacheProperties.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package run.halo.app.infra.properties; | ||
|
||
import lombok.Data; | ||
|
||
@Data | ||
public class CacheProperties { | ||
|
||
private boolean disabled = true; | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.