Skip to content

Commit

Permalink
Add support for caching template rendering result (#4091)
Browse files Browse the repository at this point in the history
#### 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
JohnNiang authored Jun 26, 2023
1 parent 2791d2f commit d0526ec
Show file tree
Hide file tree
Showing 19 changed files with 537 additions and 7 deletions.
4 changes: 4 additions & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ dependencies {
api 'org.springframework.security:spring-security-oauth2-client'
api 'org.springframework.security:spring-security-oauth2-resource-server'

// Cache
api "org.springframework.boot:spring-boot-starter-cache"
api "com.github.ben-manes.caffeine:caffeine"

api "org.springdoc:springdoc-openapi-starter-webflux-ui"
api 'org.openapi4j:openapi-schema-validator'
api "net.bytebuddy:byte-buddy"
Expand Down
52 changes: 52 additions & 0 deletions application/src/main/java/run/halo/app/cache/CacheEndpoint.java
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 application/src/main/java/run/halo/app/cache/CacheWebFilter.java
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 application/src/main/java/run/halo/app/cache/CachedResponse.java
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) {

}
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);
}
}
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;

}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public class HaloProperties implements Validator {
@Valid
private final AttachmentProperties attachment = new AttachmentProperties();

@Valid
private final CacheProperties cache = new CacheProperties();

@Override
public boolean supports(Class<?> clazz) {
return HaloProperties.class.isAssignableFrom(clazz);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.cache.CacheWebFilter;
import run.halo.app.theme.finders.FinderRegistry;

@Component("thymeleafReactiveViewResolver")
Expand Down Expand Up @@ -53,8 +53,8 @@ public Mono<Void> render(Map<String, ?> model, MediaType contentType,
return themeResolver.getTheme(exchange).flatMap(theme -> {
// calculate the engine before rendering
setTemplateEngine(engineManager.getTemplateEngine(theme));
return super.render(model, contentType, exchange)
.subscribeOn(Schedulers.boundedElastic());
exchange.getAttributes().put(CacheWebFilter.REQUEST_TO_CACHE, true);
return super.render(model, contentType, exchange);
});
}

Expand Down
8 changes: 6 additions & 2 deletions application/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ server:
enabled: true
error:
whitelabel:
enabled: false
enabled: false
spring:
output:
ansi:
Expand All @@ -27,6 +27,10 @@ spring:
cache:
cachecontrol:
max-age: 365d
cache:
type: caffeine
caffeine:
spec: expireAfterAccess=1h, maximumSize=10000

halo:
work-dir: ${user.home}/.halo2
Expand Down Expand Up @@ -56,7 +60,7 @@ management:
endpoints:
web:
exposure:
include: ["health", "info", "startup", "globalinfo", "logfile"]
include: ["health", "info", "startup", "globalinfo", "logfile"]
endpoint:
health:
probes:
Expand Down
1 change: 1 addition & 0 deletions console/packages/api-client/src/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ api/storage-halo-run-v1alpha1-policy-api.ts
api/storage-halo-run-v1alpha1-policy-template-api.ts
api/theme-halo-run-v1alpha1-theme-api.ts
api/v1alpha1-annotation-setting-api.ts
api/v1alpha1-cache-api.ts
api/v1alpha1-config-map-api.ts
api/v1alpha1-menu-api.ts
api/v1alpha1-menu-item-api.ts
Expand Down
1 change: 1 addition & 0 deletions console/packages/api-client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export * from "./api/storage-halo-run-v1alpha1-policy-api";
export * from "./api/storage-halo-run-v1alpha1-policy-template-api";
export * from "./api/theme-halo-run-v1alpha1-theme-api";
export * from "./api/v1alpha1-annotation-setting-api";
export * from "./api/v1alpha1-cache-api";
export * from "./api/v1alpha1-config-map-api";
export * from "./api/v1alpha1-menu-api";
export * from "./api/v1alpha1-menu-item-api";
Expand Down
Loading

0 comments on commit d0526ec

Please sign in to comment.