Skip to content

Commit

Permalink
feat: add basic features (#1)
Browse files Browse the repository at this point in the history
添加最基本的页面静态缓存功能。

```release-note
None
```
  • Loading branch information
ruibaby authored Jul 2, 2024
1 parent e2d91f1 commit d33b531
Show file tree
Hide file tree
Showing 19 changed files with 3,734 additions and 2,866 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ jobs:
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-ci.yaml@v1
with:
ui-path: "ui"
node-version: "20"
pnpm-version: "9"
80 changes: 14 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,76 +1,24 @@
# plugin-starter
# 页面静态缓存

Halo 2.0 插件开发快速开始模板
Halo 2 提供页面的静态缓存功能,提高页面访问速度

## 开发环境
## 使用

插件开发的详细文档请查阅:<https://docs.halo.run/developer-guide/plugin/introduction>
1. 在内置的 Halo 应用市场搜索 **页面静态缓存** 或访问 <https://www.halo.run/store/apps/app-BaamQ> 手动下载并安装。
2. 启动插件之后会自动生效。

所需环境:
## 注意事项

1. Java 17
2. Node 18
3. pnpm 8
4. Docker (可选)
1. 当前不支持监听网站内容的变动,如果网站有内容更新并需要立即展示给访客,请点击 Console 右下角的按钮手动刷新缓存。
2. 页面缓存仅对未登录的访客有效,登录用户不会使用缓存。
3. 缓存失效策略:TODO

克隆项目:
## 预览

```bash
git clone [email protected]:halo-sigs/plugin-starter.git
启用插件前:

# 或者当你 fork 之后
![Before](./images/plugin-page-cache-preview-before.jpg)

git clone [email protected]:{your_github_id}/plugin-starter.git
```
启用插件后:

```bash
cd path/to/plugin-starter
```

### 运行方式 1(推荐)

> 此方式需要本地安装 Docker
```bash
# macOS / Linux
./gradlew pnpmInstall

# Windows
./gradlew.bat pnpmInstall
```

```bash
# macOS / Linux
./gradlew haloServer

# Windows
./gradlew.bat haloServer
```

执行此命令后,会自动创建一个 Halo 的 Docker 容器并加载当前的插件,更多文档可查阅:<https://docs.halo.run/developer-guide/plugin/basics/devtools>

### 运行方式 2

> 此方式需要使用源码运行 Halo
编译插件:

```bash
# macOS / Linux
./gradlew build

# Windows
./gradlew.bat build
```

修改 Halo 配置文件:

```yaml
halo:
plugin:
runtime-mode: development
fixedPluginPath:
- "/path/to/plugin-starter"
```
最后重启 Halo 项目即可。
![After](./images/plugin-page-cache-preview-after.jpg)
7 changes: 4 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
id "run.halo.plugin.devtools" version "0.0.9"
}

group 'run.halo.starter'
group 'run.halo.cache.page'
sourceCompatibility = JavaVersion.VERSION_17

repositories {
Expand All @@ -16,7 +16,7 @@ repositories {
}

dependencies {
implementation platform('run.halo.tools.platform:plugin:2.11.0-SNAPSHOT')
implementation platform('run.halo.tools.platform:plugin:2.17.0-SNAPSHOT')
compileOnly 'run.halo.app:api'

testImplementation 'run.halo.app:api'
Expand Down Expand Up @@ -52,5 +52,6 @@ build {
}

halo {
version = '2.11'
version = '2.17.0-beta.1'
debug = true
}
Binary file added images/plugin-page-cache-preview-after.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/plugin-page-cache-preview-before.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ pluginManagement {
gradlePluginPortal()
}
}
rootProject.name = 'plugin-starter'
rootProject.name = 'plugin-page-cache'

54 changes: 54 additions & 0 deletions src/main/java/run/halo/cache/page/CacheEndpoint.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package run.halo.cache.page;

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.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;

@Component
public class CacheEndpoint implements CustomEndpoint {

private final CacheManager cacheManager;

public CacheEndpoint(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}

@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "CacheV1alpha1Console";
return SpringdocRouteBuilder.route()
.DELETE("/caches/page", this::evictCache, builder -> {
builder
.tag(tag)
.operationId("EvictPageCache")
.description("Evict Page cache.")
.response(Builder.responseBuilder()
.responseCode(String.valueOf(NO_CONTENT.value())));
})
.build();
}

private Mono<ServerResponse> evictCache(ServerRequest request) {
if (cacheManager.getCacheNames().contains("page")) {
var cache = cacheManager.getCache("page");
if (cache != null) {
cache.invalidate();
}
}
return ServerResponse.accepted().build();
}

@Override
public GroupVersion groupVersion() {
return new GroupVersion("console.api.cache.halo.run", "v1alpha1");
}
}
18 changes: 18 additions & 0 deletions src/main/java/run/halo/cache/page/CachedResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package run.halo.cache.page;

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
@@ -1,4 +1,4 @@
package run.halo.starter;
package run.halo.cache.page;

import org.springframework.stereotype.Component;
import run.halo.app.plugin.BasePlugin;
Expand All @@ -13,19 +13,9 @@
* @since 1.0.0
*/
@Component
public class StarterPlugin extends BasePlugin {
public class PageCachePlugin extends BasePlugin {

public StarterPlugin(PluginContext pluginContext) {
public PageCachePlugin(PluginContext pluginContext) {
super(pluginContext);
}

@Override
public void start() {
System.out.println("插件启动成功!");
}

@Override
public void stop() {
System.out.println("插件停止!");
}
}
151 changes: 151 additions & 0 deletions src/main/java/run/halo/cache/page/PageCacheWebFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package run.halo.cache.page;

import static java.nio.ByteBuffer.allocateDirect;
import static org.springframework.http.HttpHeaders.CACHE_CONTROL;

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.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.web.server.context.ServerSecurityContextRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.security.AdditionalWebFilter;
import run.halo.app.theme.router.ModelConst;

@Slf4j
@Component
public class PageCacheWebFilter implements AdditionalWebFilter {

public static final String CACHE_NAME = "page";

private final Cache cache;

private final ServerSecurityContextRepository serverSecurityContextRepository;

public PageCacheWebFilter(CacheManager cacheManager,
ServerSecurityContextRepository serverSecurityContextRepository) {
this.cache = cacheManager.getCache(CACHE_NAME);
this.serverSecurityContextRepository = serverSecurityContextRepository;
}

private static boolean hasRequestBody(ServerHttpRequest request) {
return request.getHeaders().getContentLength() > 0;
}

@Override
@NonNull
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
return serverSecurityContextRepository.load(exchange)
.switchIfEmpty(Mono.defer(() -> {
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).then(
Mono.empty());
}
// decorate the ServerHttpResponse to cache the response
var decoratedExchange = exchange.mutate()
.response(new CacheResponseDecorator(exchange, cacheKey))
.build();
return chain.filter(decoratedExchange).then(Mono.empty());
}))
.flatMap(securityContext -> chain.filter(exchange).then(Mono.empty()));
}

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(ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE, false);
}

private String generateCacheKey(ServerHttpRequest request) {
return request.getURI().toASCIIString();
}

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);
}
}
}
9 changes: 9 additions & 0 deletions src/main/resources/extensions/extension.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
name: page-cache-handler
spec:
className: run.halo.cache.page.PageCacheWebFilter
extensionPointName: additional-webfilter
displayName: "页面静态缓存"
description: "通过拦截页面请求,以实现页面的静态缓存功能,提高页面访问速度。"
Binary file removed src/main/resources/logo.png
Binary file not shown.
1 change: 1 addition & 0 deletions src/main/resources/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit d33b531

Please sign in to comment.