mirror of https://github.com/halo-dev/halo
perf: optimizing request plugin bundle resources (#4639)
#### What type of PR is this? /kind improvement /area core /milestone 2.10.x #### What this PR does / why we need it: 优化插件捆绑资源的查询性能 在插件 js bundle 1.2M 大小的情况下: 优化前: <img width="773" alt="image" src="https://github.com/halo-dev/halo/assets/38999863/e8e3b995-c8e9-44d7-b0ed-29eb82b975c5"> 优化后: <img width="765" alt="image" src="https://github.com/halo-dev/halo/assets/38999863/3860863e-1293-4713-ba6b-b101dec3a1e4"> how to test it? 在插件生产模式下使用此 PR 测试,比对与不使用此 PR 的 API 速度,且检查 js bundle 和 css bundle 的资源是否正确加载。 #### Does this PR introduce a user-facing change? ```release-note 优化插件捆绑资源(JSBundle)的查询性能以提高页面加载速度 ```pull/4669/head
parent
610609656d
commit
ce0e02a167
|
@ -19,11 +19,13 @@ import static run.halo.app.infra.utils.FileUtils.deleteFileSilently;
|
||||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -31,13 +33,21 @@ import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.concurrent.locks.ReadWriteLock;
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.reactivestreams.Publisher;
|
import org.reactivestreams.Publisher;
|
||||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||||
|
import org.springframework.beans.factory.DisposableBean;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.buffer.DataBuffer;
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||||
import org.springframework.dao.OptimisticLockingFailureException;
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.http.CacheControl;
|
import org.springframework.http.CacheControl;
|
||||||
|
@ -45,14 +55,19 @@ import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.codec.multipart.FilePart;
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
import org.springframework.http.codec.multipart.FormFieldPart;
|
import org.springframework.http.codec.multipart.FormFieldPart;
|
||||||
import org.springframework.http.codec.multipart.Part;
|
import org.springframework.http.codec.multipart.Part;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.FileSystemUtils;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import org.springframework.web.server.ServerWebExchange;
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.scheduler.Scheduler;
|
import reactor.core.scheduler.Scheduler;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
@ -82,6 +97,8 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private final Scheduler scheduler = Schedulers.boundedElastic();
|
private final Scheduler scheduler = Schedulers.boundedElastic();
|
||||||
|
|
||||||
|
private final BufferedPluginBundleResource bufferedPluginBundleResource;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RouterFunction<ServerResponse> endpoint() {
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
final var tag = "api.console.halo.run/v1alpha1/Plugin";
|
final var tag = "api.console.halo.run/v1alpha1/Plugin";
|
||||||
|
@ -240,36 +257,59 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private Mono<ServerResponse> fetchJsBundle(ServerRequest request) {
|
private Mono<ServerResponse> fetchJsBundle(ServerRequest request) {
|
||||||
Optional<String> versionOption = request.queryParam("v");
|
Optional<String> versionOption = request.queryParam("v");
|
||||||
if (versionOption.isEmpty()) {
|
return versionOption.map(s ->
|
||||||
return pluginService.generateJsBundleVersion()
|
Mono.defer(() -> bufferedPluginBundleResource
|
||||||
|
.getJsBundle(s, pluginService::uglifyJsBundle)
|
||||||
|
).flatMap(fsRes -> {
|
||||||
|
var bodyBuilder = ServerResponse.ok()
|
||||||
|
.cacheControl(MAX_CACHE_CONTROL)
|
||||||
|
.contentType(MediaType.valueOf("text/javascript"));
|
||||||
|
try {
|
||||||
|
Instant lastModified = Instant.ofEpochMilli(fsRes.lastModified());
|
||||||
|
return request.checkNotModified(lastModified)
|
||||||
|
.switchIfEmpty(Mono.defer(() ->
|
||||||
|
bodyBuilder.lastModified(lastModified)
|
||||||
|
.body(BodyInserters.fromResource(fsRes)))
|
||||||
|
);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Mono.error(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.orElseGet(() -> pluginService.generateJsBundleVersion()
|
||||||
.flatMap(v -> ServerResponse
|
.flatMap(v -> ServerResponse
|
||||||
.temporaryRedirect(buildJsBundleUri("js", v))
|
.temporaryRedirect(buildJsBundleUri("js", v))
|
||||||
.build()
|
.build()
|
||||||
);
|
)
|
||||||
}
|
|
||||||
return pluginService.uglifyJsBundle()
|
|
||||||
.defaultIfEmpty("")
|
|
||||||
.flatMap(bundle -> ServerResponse.ok()
|
|
||||||
.cacheControl(MAX_CACHE_CONTROL)
|
|
||||||
.contentType(MediaType.valueOf("text/javascript"))
|
|
||||||
.bodyValue(bundle)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
|
private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
|
||||||
Optional<String> versionOption = request.queryParam("v");
|
Optional<String> versionOption = request.queryParam("v");
|
||||||
if (versionOption.isEmpty()) {
|
return versionOption.map(s ->
|
||||||
return pluginService.generateJsBundleVersion()
|
Mono.defer(() -> bufferedPluginBundleResource.getCssBundle(s,
|
||||||
|
pluginService::uglifyCssBundle)
|
||||||
|
).flatMap(fsRes -> {
|
||||||
|
var bodyBuilder = ServerResponse.ok()
|
||||||
|
.cacheControl(MAX_CACHE_CONTROL)
|
||||||
|
.contentType(MediaType.valueOf("text/css"));
|
||||||
|
try {
|
||||||
|
Instant lastModified = Instant.ofEpochMilli(fsRes.lastModified());
|
||||||
|
return request.checkNotModified(lastModified)
|
||||||
|
.switchIfEmpty(Mono.defer(() ->
|
||||||
|
bodyBuilder.lastModified(lastModified)
|
||||||
|
.body(BodyInserters.fromResource(fsRes)))
|
||||||
|
);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Mono.error(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.orElseGet(() -> pluginService.generateJsBundleVersion()
|
||||||
.flatMap(v -> ServerResponse
|
.flatMap(v -> ServerResponse
|
||||||
.temporaryRedirect(buildJsBundleUri("css", v))
|
.temporaryRedirect(buildJsBundleUri("css", v))
|
||||||
.build()
|
.build()
|
||||||
);
|
)
|
||||||
}
|
|
||||||
return pluginService.uglifyCssBundle()
|
|
||||||
.flatMap(bundle -> ServerResponse.ok()
|
|
||||||
.cacheControl(MAX_CACHE_CONTROL)
|
|
||||||
.contentType(MediaType.valueOf("text/css"))
|
|
||||||
.bodyValue(bundle)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -353,7 +393,7 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
if (!configMapName.equals(configMapNameToUpdate)) {
|
if (!configMapName.equals(configMapNameToUpdate)) {
|
||||||
throw new ServerWebInputException(
|
throw new ServerWebInputException(
|
||||||
"The name from the request body does not match the plugin "
|
"The name from the request body does not match the plugin "
|
||||||
+ "configMapName name.");
|
+ "configMapName name.");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.flatMap(configMapToUpdate -> client.fetch(ConfigMap.class, configMapName)
|
.flatMap(configMapToUpdate -> client.fetch(ConfigMap.class, configMapName)
|
||||||
|
@ -482,7 +522,7 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
@ArraySchema(uniqueItems = true,
|
@ArraySchema(uniqueItems = true,
|
||||||
arraySchema = @Schema(name = "sort",
|
arraySchema = @Schema(name = "sort",
|
||||||
description = "Sort property and direction of the list result. Supported fields: "
|
description = "Sort property and direction of the list result. Supported fields: "
|
||||||
+ "creationTimestamp"),
|
+ "creationTimestamp"),
|
||||||
schema = @Schema(description = "like field,asc or field,desc",
|
schema = @Schema(description = "like field,asc or field,desc",
|
||||||
implementation = String.class,
|
implementation = String.class,
|
||||||
example = "creationTimestamp,desc"))
|
example = "creationTimestamp,desc"))
|
||||||
|
@ -633,4 +673,111 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
.subscribeOn(this.scheduler);
|
.subscribeOn(this.scheduler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class BufferedPluginBundleResource implements DisposableBean {
|
||||||
|
|
||||||
|
private final AtomicReference<FileSystemResource> jsBundle = new AtomicReference<>();
|
||||||
|
private final AtomicReference<FileSystemResource> cssBundle = new AtomicReference<>();
|
||||||
|
|
||||||
|
private final ReadWriteLock jsLock = new ReentrantReadWriteLock();
|
||||||
|
private final ReadWriteLock cssLock = new ReentrantReadWriteLock();
|
||||||
|
|
||||||
|
private Path tempDir;
|
||||||
|
|
||||||
|
public Mono<FileSystemResource> getJsBundle(String version,
|
||||||
|
Supplier<Flux<DataBuffer>> jsSupplier) {
|
||||||
|
var fileName = tempFileName(version, ".js");
|
||||||
|
return Mono.defer(() -> {
|
||||||
|
jsLock.readLock().lock();
|
||||||
|
try {
|
||||||
|
var jsBundleResource = jsBundle.get();
|
||||||
|
if (getResourceIfNotChange(fileName, jsBundleResource) != null) {
|
||||||
|
return Mono.just(jsBundleResource);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
jsLock.readLock().unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
jsLock.writeLock().lock();
|
||||||
|
try {
|
||||||
|
var oldJsBundle = jsBundle.get();
|
||||||
|
return writeBundle(fileName, jsSupplier)
|
||||||
|
.doOnNext(newRes -> jsBundle.compareAndSet(oldJsBundle, newRes));
|
||||||
|
} finally {
|
||||||
|
jsLock.writeLock().unlock();
|
||||||
|
}
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<FileSystemResource> getCssBundle(String version,
|
||||||
|
Supplier<Flux<DataBuffer>> cssSupplier) {
|
||||||
|
var fileName = tempFileName(version, ".css");
|
||||||
|
return Mono.defer(() -> {
|
||||||
|
try {
|
||||||
|
cssLock.readLock().lock();
|
||||||
|
var cssBundleResource = cssBundle.get();
|
||||||
|
if (getResourceIfNotChange(fileName, cssBundleResource) != null) {
|
||||||
|
return Mono.just(cssBundleResource);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cssLock.readLock().unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
cssLock.writeLock().lock();
|
||||||
|
try {
|
||||||
|
var oldCssBundle = cssBundle.get();
|
||||||
|
return writeBundle(fileName, cssSupplier)
|
||||||
|
.doOnNext(newRes -> cssBundle.compareAndSet(oldCssBundle, newRes));
|
||||||
|
} finally {
|
||||||
|
cssLock.writeLock().unlock();
|
||||||
|
}
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private Resource getResourceIfNotChange(String fileName, Resource resource) {
|
||||||
|
if (resource != null && resource.exists() && fileName.equals(resource.getFilename())) {
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<FileSystemResource> writeBundle(String fileName,
|
||||||
|
Supplier<Flux<DataBuffer>> dataSupplier) {
|
||||||
|
return Mono.defer(
|
||||||
|
() -> {
|
||||||
|
var filePath = createTempFileToStore(fileName);
|
||||||
|
return DataBufferUtils.write(dataSupplier.get(), filePath)
|
||||||
|
.then(Mono.fromSupplier(() -> new FileSystemResource(filePath)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path createTempFileToStore(String fileName) {
|
||||||
|
try {
|
||||||
|
if (tempDir == null || !Files.exists(tempDir)) {
|
||||||
|
this.tempDir = Files.createTempDirectory("halo-plugin-bundle");
|
||||||
|
}
|
||||||
|
var path = tempDir.resolve(fileName);
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
return Files.createFile(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ServerWebInputException("Failed to create temp file.", null, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tempFileName(String v, String suffix) {
|
||||||
|
Assert.notNull(v, "Version must not be null");
|
||||||
|
Assert.notNull(suffix, "Suffix must not be null");
|
||||||
|
return v + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void destroy() throws Exception {
|
||||||
|
if (tempDir != null && Files.exists(tempDir)) {
|
||||||
|
FileSystemUtils.deleteRecursively(tempDir);
|
||||||
|
}
|
||||||
|
this.jsBundle.set(null);
|
||||||
|
this.cssBundle.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package run.halo.app.core.extension.service;
|
package run.halo.app.core.extension.service;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
@ -46,14 +47,14 @@ public interface PluginService {
|
||||||
*
|
*
|
||||||
* @return uglified js bundle
|
* @return uglified js bundle
|
||||||
*/
|
*/
|
||||||
Mono<String> uglifyJsBundle();
|
Flux<DataBuffer> uglifyJsBundle();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uglify css bundle from all enabled plugins to a single css bundle string.
|
* Uglify css bundle from all enabled plugins to a single css bundle string.
|
||||||
*
|
*
|
||||||
* @return uglified css bundle
|
* @return uglified css bundle
|
||||||
*/
|
*/
|
||||||
Mono<String> uglifyCssBundle();
|
Flux<DataBuffer> uglifyCssBundle();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>Generate js bundle version for cache control.</p>
|
* <p>Generate js bundle version for cache control.</p>
|
||||||
|
|
|
@ -9,7 +9,6 @@ import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -21,9 +20,13 @@ import org.apache.commons.lang3.Validate;
|
||||||
import org.pf4j.PluginWrapper;
|
import org.pf4j.PluginWrapper;
|
||||||
import org.pf4j.RuntimeMode;
|
import org.pf4j.RuntimeMode;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||||
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.StreamUtils;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.Exceptions;
|
import reactor.core.Exceptions;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
@ -133,55 +136,52 @@ public class PluginServiceImpl implements PluginService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<String> uglifyJsBundle() {
|
public Flux<DataBuffer> uglifyJsBundle() {
|
||||||
return Mono.fromSupplier(() -> {
|
var startedPlugins = List.copyOf(pluginManager.getStartedPlugins());
|
||||||
StringBuilder jsBundle = new StringBuilder();
|
String plugins = """
|
||||||
List<String> pluginNames = new ArrayList<>();
|
this.enabledPluginNames = [%s];
|
||||||
for (PluginWrapper pluginWrapper : pluginManager.getStartedPlugins()) {
|
""".formatted(startedPlugins.stream()
|
||||||
String pluginName = pluginWrapper.getPluginId();
|
.map(PluginWrapper::getPluginId)
|
||||||
pluginNames.add(pluginName);
|
.collect(Collectors.joining("','", "'", "'")));
|
||||||
Resource jsBundleResource =
|
return Flux.fromIterable(startedPlugins)
|
||||||
BundleResourceUtils.getJsBundleResource(pluginManager, pluginName,
|
.mapNotNull(pluginWrapper -> {
|
||||||
BundleResourceUtils.JS_BUNDLE);
|
var pluginName = pluginWrapper.getPluginId();
|
||||||
if (jsBundleResource != null) {
|
return BundleResourceUtils.getJsBundleResource(pluginManager, pluginName,
|
||||||
try {
|
BundleResourceUtils.JS_BUNDLE);
|
||||||
jsBundle.append(
|
})
|
||||||
jsBundleResource.getContentAsString(StandardCharsets.UTF_8));
|
.flatMap(resource -> {
|
||||||
jsBundle.append("\n");
|
try {
|
||||||
} catch (IOException e) {
|
// Specifying bufferSize as resource content length is
|
||||||
log.error("Failed to read js bundle of plugin [{}]", pluginName, e);
|
// to append line breaks at the end of each plugin
|
||||||
}
|
return DataBufferUtils.read(resource, DefaultDataBufferFactory.sharedInstance,
|
||||||
|
(int) resource.contentLength())
|
||||||
|
.doOnNext(dataBuffer -> {
|
||||||
|
// add a new line after each plugin bundle to avoid syntax error
|
||||||
|
dataBuffer.write("\n".getBytes(StandardCharsets.UTF_8));
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to read plugin bundle resource", e);
|
||||||
|
return Flux.empty();
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.concatWith(Flux.defer(() -> {
|
||||||
String plugins = """
|
var dataBuffer = DefaultDataBufferFactory.sharedInstance
|
||||||
this.enabledPluginNames = [%s];
|
.wrap(plugins.getBytes(StandardCharsets.UTF_8));
|
||||||
""".formatted(pluginNames.stream()
|
return Flux.just(dataBuffer);
|
||||||
.collect(Collectors.joining("','", "'", "'")));
|
}));
|
||||||
return jsBundle + plugins;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<String> uglifyCssBundle() {
|
public Flux<DataBuffer> uglifyCssBundle() {
|
||||||
return Mono.fromSupplier(() -> {
|
return Flux.fromIterable(pluginManager.getStartedPlugins())
|
||||||
StringBuilder cssBundle = new StringBuilder();
|
.mapNotNull(pluginWrapper -> {
|
||||||
for (PluginWrapper pluginWrapper : pluginManager.getStartedPlugins()) {
|
|
||||||
String pluginName = pluginWrapper.getPluginId();
|
String pluginName = pluginWrapper.getPluginId();
|
||||||
Resource cssBundleResource =
|
return BundleResourceUtils.getJsBundleResource(pluginManager, pluginName,
|
||||||
BundleResourceUtils.getJsBundleResource(pluginManager, pluginName,
|
BundleResourceUtils.CSS_BUNDLE);
|
||||||
BundleResourceUtils.CSS_BUNDLE);
|
})
|
||||||
if (cssBundleResource != null) {
|
.flatMap(resource -> DataBufferUtils.read(resource,
|
||||||
try {
|
DefaultDataBufferFactory.sharedInstance, StreamUtils.BUFFER_SIZE)
|
||||||
cssBundle.append(
|
);
|
||||||
cssBundleResource.getContentAsString(StandardCharsets.UTF_8));
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Failed to read css bundle of plugin [{}]", pluginName, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cssBundle.toString();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -259,7 +259,7 @@ public class PluginServiceImpl implements PluginService {
|
||||||
if (!VersionUtils.satisfiesRequires(systemVersion, requires)) {
|
if (!VersionUtils.satisfiesRequires(systemVersion, requires)) {
|
||||||
throw new UnsatisfiedAttributeValueException(String.format(
|
throw new UnsatisfiedAttributeValueException(String.format(
|
||||||
"Plugin requires a minimum system version of [%s], but the current version is "
|
"Plugin requires a minimum system version of [%s], but the current version is "
|
||||||
+ "[%s].",
|
+ "[%s].",
|
||||||
requires, systemVersion),
|
requires, systemVersion),
|
||||||
"problemDetail.plugin.version.unsatisfied.requires",
|
"problemDetail.plugin.version.unsatisfied.requires",
|
||||||
new String[] {requires, systemVersion});
|
new String[] {requires, systemVersion});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package run.halo.app.core.extension.endpoint;
|
package run.halo.app.core.extension.endpoint;
|
||||||
|
|
||||||
import static java.util.Objects.requireNonNull;
|
import static java.util.Objects.requireNonNull;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
@ -16,6 +17,7 @@ import static org.springframework.web.reactive.function.BodyInserters.fromMultip
|
||||||
import com.github.zafarkhaja.semver.Version;
|
import com.github.zafarkhaja.semver.Version;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
@ -33,11 +35,15 @@ import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.client.MultipartBodyBuilder;
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
import run.halo.app.core.extension.Plugin;
|
import run.halo.app.core.extension.Plugin;
|
||||||
import run.halo.app.core.extension.Setting;
|
import run.halo.app.core.extension.Setting;
|
||||||
import run.halo.app.core.extension.service.PluginService;
|
import run.halo.app.core.extension.service.PluginService;
|
||||||
|
@ -130,8 +136,8 @@ class PluginEndpointTest {
|
||||||
|
|
||||||
verify(client).list(same(Plugin.class), argThat(
|
verify(client).list(same(Plugin.class), argThat(
|
||||||
predicate -> predicate.test(expectPlugin)
|
predicate -> predicate.test(expectPlugin)
|
||||||
&& !predicate.test(unexpectedPlugin1)
|
&& !predicate.test(unexpectedPlugin1)
|
||||||
&& !predicate.test(unexpectedPlugin2)),
|
&& !predicate.test(unexpectedPlugin2)),
|
||||||
any(), anyInt(), anyInt());
|
any(), anyInt(), anyInt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,8 +164,8 @@ class PluginEndpointTest {
|
||||||
|
|
||||||
verify(client).list(same(Plugin.class), argThat(
|
verify(client).list(same(Plugin.class), argThat(
|
||||||
predicate -> predicate.test(expectPlugin)
|
predicate -> predicate.test(expectPlugin)
|
||||||
&& !predicate.test(unexpectedPlugin1)
|
&& !predicate.test(unexpectedPlugin1)
|
||||||
&& !predicate.test(unexpectedPlugin2)),
|
&& !predicate.test(unexpectedPlugin2)),
|
||||||
any(), anyInt(), anyInt());
|
any(), anyInt(), anyInt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -382,4 +388,90 @@ class PluginEndpointTest {
|
||||||
plugin.setSpec(spec);
|
plugin.setSpec(spec);
|
||||||
return plugin;
|
return plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class BufferedPluginBundleResourceTest {
|
||||||
|
private final PluginEndpoint.BufferedPluginBundleResource bufferedPluginBundleResource =
|
||||||
|
new PluginEndpoint.BufferedPluginBundleResource();
|
||||||
|
|
||||||
|
private static Flux<DataBuffer> getDataBufferFlux(String x) {
|
||||||
|
var buffer = DefaultDataBufferFactory.sharedInstance
|
||||||
|
.wrap(x.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Flux.just(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeAndGetJsResourceTest() {
|
||||||
|
bufferedPluginBundleResource.getJsBundle("1",
|
||||||
|
() -> getDataBufferFlux("first line\nnext line"))
|
||||||
|
.as(StepVerifier::create)
|
||||||
|
.consumeNextWith(resource -> {
|
||||||
|
try {
|
||||||
|
String content = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(content).isEqualTo("first line\nnext line");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
// version is matched, should return cached content
|
||||||
|
bufferedPluginBundleResource.getJsBundle("1",
|
||||||
|
() -> getDataBufferFlux("first line\nnext line-1"))
|
||||||
|
.as(StepVerifier::create)
|
||||||
|
.consumeNextWith(resource -> {
|
||||||
|
try {
|
||||||
|
String content = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(content).isEqualTo("first line\nnext line");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
// new version should return new content
|
||||||
|
bufferedPluginBundleResource.getJsBundle("2",
|
||||||
|
() -> getDataBufferFlux("first line\nnext line-2"))
|
||||||
|
.as(StepVerifier::create)
|
||||||
|
.consumeNextWith(resource -> {
|
||||||
|
try {
|
||||||
|
String content = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(content).isEqualTo("first line\nnext line-2");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writeAndGetCssResourceTest() {
|
||||||
|
bufferedPluginBundleResource.getCssBundle("1",
|
||||||
|
() -> getDataBufferFlux("first line\nnext line"))
|
||||||
|
.as(StepVerifier::create)
|
||||||
|
.consumeNextWith(resource -> {
|
||||||
|
try {
|
||||||
|
String content = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(content).isEqualTo("first line\nnext line");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
// version is matched, should return cached content
|
||||||
|
bufferedPluginBundleResource.getCssBundle("1",
|
||||||
|
() -> getDataBufferFlux("first line\nnext line-1"))
|
||||||
|
.as(StepVerifier::create)
|
||||||
|
.consumeNextWith(resource -> {
|
||||||
|
try {
|
||||||
|
String content = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(content).isEqualTo("first line\nnext line");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue