mirror of https://github.com/halo-dev/halo
Add event handling for attachment changes and update thumbnail service integration
parent
68e80d16ed
commit
230550d0df
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.core.attachment;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
|
||||
/**
|
||||
* Event triggered when an attachment is created, updated, or deleted.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class AttachmentChangedEvent extends ApplicationEvent {
|
||||
|
||||
@Getter
|
||||
private final Attachment attachment;
|
||||
|
||||
public AttachmentChangedEvent(Object source, Attachment attachment) {
|
||||
super(source);
|
||||
this.attachment = attachment;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,33 +1,32 @@
|
|||
package run.halo.app.core.attachment;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.ExternalLinkProcessor;
|
||||
|
||||
/**
|
||||
* Service for managing thumbnails.
|
||||
*
|
||||
* @author johnniang
|
||||
* @since 2.22.0
|
||||
*/
|
||||
public interface ThumbnailService {
|
||||
|
||||
/**
|
||||
* Generate thumbnail by the given image uri and size.
|
||||
* <p>if the imageUri is not absolute, it will be processed by {@link ExternalLinkProcessor}
|
||||
* .</p>
|
||||
* <p>if externalUrl is not configured, it will return empty.</p>
|
||||
* Get the thumbnail link for the given image URI and size.
|
||||
*
|
||||
* @param imageUri image uri to generate thumbnail
|
||||
* @param size thumbnail size to generate
|
||||
* @return generated thumbnail uri if success, otherwise empty.
|
||||
* @param permalink the permalink of the image
|
||||
* @param size the size of the thumbnail
|
||||
* @return the thumbnail link
|
||||
*/
|
||||
Mono<URI> generate(URI imageUri, ThumbnailSize size);
|
||||
Mono<URI> get(URI permalink, ThumbnailSize size);
|
||||
|
||||
/**
|
||||
* <p>Get thumbnail by the given image uri and size.</p>
|
||||
* <p>It depends on the {@link #generate(URI, ThumbnailSize)} method, currently the thumbnail
|
||||
* generation is limited to the attachment service, that is, the thumbnail is strongly
|
||||
* associated with the attachment.</p>
|
||||
* Get all thumbnail links for the given image URI.
|
||||
*
|
||||
* @return if thumbnail exists, return the thumbnail uri, otherwise return the original image
|
||||
* uri
|
||||
* @param permalink the permalink of the image
|
||||
* @return the map of thumbnail size to thumbnail link
|
||||
*/
|
||||
Mono<URI> get(URI imageUri, ThumbnailSize size);
|
||||
Mono<Map<ThumbnailSize, URI>> get(URI permalink);
|
||||
|
||||
Mono<Void> delete(URI imageUri);
|
||||
}
|
||||
|
|
|
@ -1,162 +1,100 @@
|
|||
package run.halo.app.core.attachment.impl;
|
||||
|
||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.startsWith;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.attachment.AttachmentUtils;
|
||||
import run.halo.app.core.attachment.LocalThumbnailService;
|
||||
import run.halo.app.core.attachment.ThumbnailProvider;
|
||||
import run.halo.app.core.attachment.ThumbnailProvider.ThumbnailContext;
|
||||
import run.halo.app.core.attachment.AttachmentChangedEvent;
|
||||
import run.halo.app.core.attachment.ThumbnailService;
|
||||
import run.halo.app.core.attachment.ThumbnailSigner;
|
||||
import run.halo.app.core.attachment.ThumbnailSize;
|
||||
import run.halo.app.core.attachment.extension.Thumbnail;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalLinkProcessor;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ThumbnailServiceImpl implements ThumbnailService {
|
||||
private final ExtensionGetter extensionGetter;
|
||||
class ThumbnailServiceImpl implements ThumbnailService {
|
||||
|
||||
private final Cache<String, Map<ThumbnailSize, URI>> thumbnailCache;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ExternalLinkProcessor externalLinkProcessor;
|
||||
private final ThumbnailProvider thumbnailProvider;
|
||||
private final LocalThumbnailService localThumbnailService;
|
||||
private final Map<CacheKey, Mono<URI>> ongoingTasks = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Mono<URI> generate(URI imageUri, ThumbnailSize size) {
|
||||
var cacheKey = new CacheKey(imageUri, size);
|
||||
// Combine caching to implement more elegant deduplication logic, ensure that only
|
||||
// one thread executes the logic of create at the same time, and there is no global lock
|
||||
// restriction
|
||||
return ongoingTasks.computeIfAbsent(cacheKey, k -> doGenerate(imageUri, size)
|
||||
// In the case of concurrency, doGenerate must return the same instance
|
||||
.doFinally(signalType -> ongoingTasks.remove(cacheKey))
|
||||
.cache()
|
||||
);
|
||||
}
|
||||
|
||||
record CacheKey(URI imageUri, ThumbnailSize size) {
|
||||
}
|
||||
|
||||
private Mono<URI> doGenerate(URI imageUri, ThumbnailSize size) {
|
||||
var imageUrlOpt = toImageUrl(imageUri);
|
||||
if (imageUrlOpt.isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var imageUrl = imageUrlOpt.get();
|
||||
return fetchThumbnail(imageUri, size)
|
||||
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
|
||||
.switchIfEmpty(Mono.defer(() -> create(imageUrl, size)))
|
||||
.onErrorResume(Throwable.class, e -> {
|
||||
log.warn("Failed to generate thumbnail for image: {}", imageUrl, e);
|
||||
return Mono.just(URI.create(imageUrl.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<URI> get(URI imageUri, ThumbnailSize size) {
|
||||
return fetchThumbnail(imageUri, size)
|
||||
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
|
||||
.defaultIfEmpty(imageUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> delete(URI imageUri) {
|
||||
Assert.notNull(imageUri, "Image uri must not be null");
|
||||
Mono<Void> deleteMono;
|
||||
if (imageUri.isAbsolute()) {
|
||||
deleteMono = thumbnailProvider.delete(AttachmentUtils.toUrl(imageUri));
|
||||
} else {
|
||||
// Local thumbnails maybe a relative path, so we need to process it.
|
||||
deleteMono = localThumbnailService.delete(imageUri);
|
||||
}
|
||||
return deleteMono.then(deleteThumbnailRecord(imageUri));
|
||||
}
|
||||
|
||||
private Mono<Void> deleteThumbnailRecord(URI imageUri) {
|
||||
var imageHash = signatureFor(imageUri);
|
||||
var listOptions = ListOptions.builder()
|
||||
.fieldQuery(startsWith(Thumbnail.ID_INDEX, Thumbnail.idIndexFunc(imageHash, "")))
|
||||
public ThumbnailServiceImpl(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
this.thumbnailCache = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
return client.listAll(Thumbnail.class, listOptions, Sort.unsorted())
|
||||
.flatMap(client::delete)
|
||||
.then();
|
||||
}
|
||||
|
||||
Optional<URL> toImageUrl(URI imageUri) {
|
||||
try {
|
||||
if (imageUri.isAbsolute()) {
|
||||
return Optional.of(imageUri.toURL());
|
||||
@EventListener
|
||||
void handleAttachmentChangedEvent(AttachmentChangedEvent event) {
|
||||
invalidateOrUpdateCache(event.getAttachment());
|
||||
}
|
||||
|
||||
void invalidateOrUpdateCache(Attachment attachment) {
|
||||
if (attachment.getStatus() == null) {
|
||||
return;
|
||||
}
|
||||
var permalink = attachment.getStatus().getPermalink();
|
||||
if (!StringUtils.hasText(permalink)) {
|
||||
return;
|
||||
}
|
||||
if (ExtensionUtil.isDeleted(attachment)) {
|
||||
thumbnailCache.invalidate(permalink);
|
||||
return;
|
||||
}
|
||||
var thumbnails = attachment.getStatus().getThumbnails();
|
||||
if (CollectionUtils.isEmpty(thumbnails)) {
|
||||
thumbnailCache.put(permalink, Map.of());
|
||||
return;
|
||||
}
|
||||
|
||||
Map<ThumbnailSize, URI> validThumbnails = new HashMap<>();
|
||||
thumbnails.forEach((key, value) -> {
|
||||
var size = ThumbnailSize.optionalValueOf(key);
|
||||
if (size.isPresent() && StringUtils.hasText(value)) {
|
||||
validThumbnails.put(size.get(), URI.create(value));
|
||||
}
|
||||
var url = new URL(externalLinkProcessor.processLink(imageUri.toString()));
|
||||
return Optional.of(url);
|
||||
} catch (MalformedURLException e) {
|
||||
// Ignore
|
||||
});
|
||||
if (validThumbnails.isEmpty()) {
|
||||
thumbnailCache.put(permalink, Map.of());
|
||||
} else {
|
||||
thumbnailCache.put(permalink, validThumbnails);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
protected Mono<URI> create(URL imageUrl, ThumbnailSize size) {
|
||||
var context = ThumbnailContext.builder()
|
||||
.imageUrl(imageUrl)
|
||||
.size(size)
|
||||
@Override
|
||||
public Mono<URI> get(URI permalink, ThumbnailSize size) {
|
||||
return get(permalink).mapNotNull(thumbnails -> thumbnails.get(size));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Map<ThumbnailSize, URI>> get(URI permalink) {
|
||||
var permalinkString = permalink.toASCIIString();
|
||||
var thumbnails = thumbnailCache.getIfPresent(permalinkString);
|
||||
if (thumbnails != null) {
|
||||
return Mono.just(thumbnails);
|
||||
}
|
||||
// query from attachments
|
||||
var listOptions = ListOptions.builder()
|
||||
.andQuery(QueryFactory.equal("status.permalink", permalinkString))
|
||||
.build();
|
||||
var imageUri =
|
||||
localThumbnailService.ensureInSiteUriIsRelative(URI.create(imageUrl.toString()));
|
||||
return extensionGetter.getEnabledExtensions(ThumbnailProvider.class)
|
||||
.filterWhen(provider -> provider.supports(context))
|
||||
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
|
||||
.next()
|
||||
.flatMap(provider -> provider.generate(context))
|
||||
.flatMap(uri -> {
|
||||
var thumb = new Thumbnail();
|
||||
thumb.setMetadata(new Metadata());
|
||||
thumb.getMetadata().setGenerateName("thumb-");
|
||||
thumb.setSpec(new Thumbnail.Spec()
|
||||
.setSize(size)
|
||||
.setThumbnailUri(uri.toASCIIString())
|
||||
.setImageUri(imageUri.toASCIIString())
|
||||
.setImageSignature(signatureFor(imageUri))
|
||||
);
|
||||
// double check
|
||||
return fetchThumbnail(imageUri, size)
|
||||
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
|
||||
.switchIfEmpty(Mono.defer(() -> client.create(thumb)
|
||||
.thenReturn(uri))
|
||||
);
|
||||
.map(attachment -> {
|
||||
// Here we allow concurrent updates
|
||||
invalidateOrUpdateCache(attachment);
|
||||
return this.thumbnailCache.getIfPresent(permalinkString);
|
||||
});
|
||||
}
|
||||
|
||||
private String signatureFor(URI imageUri) {
|
||||
var uri = localThumbnailService.ensureInSiteUriIsRelative(imageUri);
|
||||
return ThumbnailSigner.generateSignature(uri);
|
||||
}
|
||||
|
||||
Mono<Thumbnail> fetchThumbnail(URI imageUri, ThumbnailSize size) {
|
||||
var imageHash = signatureFor(imageUri);
|
||||
var id = Thumbnail.idIndexFunc(imageHash, size.name());
|
||||
return client.listBy(Thumbnail.class, ListOptions.builder()
|
||||
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
|
||||
.build(), PageRequestImpl.ofSize(1))
|
||||
.flatMap(result -> Mono.justOrEmpty(ListResult.first(result)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,13 +5,13 @@ import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
|
|||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.attachment.ThumbnailService;
|
||||
import run.halo.app.core.attachment.AttachmentChangedEvent;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus;
|
||||
import run.halo.app.core.extension.attachment.Constant;
|
||||
|
@ -33,7 +33,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
|||
|
||||
private final AttachmentService attachmentService;
|
||||
|
||||
private final ThumbnailService thumbnailService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
|
@ -43,6 +43,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
|||
Set.of(Constant.FINALIZER_NAME))) {
|
||||
cleanUpResources(attachment);
|
||||
client.update(attachment);
|
||||
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -74,6 +75,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
|||
attachment.getStatus().setThumbnails(thumbnails);
|
||||
log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name());
|
||||
client.update(attachment);
|
||||
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
@ -86,12 +88,6 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
|||
}
|
||||
|
||||
void cleanUpResources(Attachment attachment) {
|
||||
var timeout = Duration.ofSeconds(20);
|
||||
Optional.ofNullable(attachment.getStatus())
|
||||
.map(AttachmentStatus::getPermalink)
|
||||
.map(URI::create)
|
||||
.ifPresent(uri -> thumbnailService.delete(uri).block(timeout));
|
||||
|
||||
attachmentService.delete(attachment).block(timeout);
|
||||
attachmentService.delete(attachment).block(Duration.ofSeconds(20));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,14 +20,11 @@ import org.springframework.web.reactive.function.server.ServerRequest;
|
|||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.attachment.ThumbnailService;
|
||||
import run.halo.app.core.attachment.ThumbnailSize;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
|
||||
/**
|
||||
* Thumbnail endpoint for thumbnail resource access.
|
||||
|
@ -39,6 +36,8 @@ import run.halo.app.extension.index.query.QueryFactory;
|
|||
@RequiredArgsConstructor
|
||||
public class ThumbnailEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ThumbnailService thumbnailService;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
@Override
|
||||
|
@ -49,32 +48,49 @@ public class ThumbnailEndpoint implements CustomEndpoint {
|
|||
builder.operationId("GetThumbnailByUri")
|
||||
.description("Get thumbnail by URI")
|
||||
.tag(tag)
|
||||
.response(responseBuilder()
|
||||
.implementation(Resource.class));
|
||||
ThumbnailQuery.buildParameters(builder);
|
||||
.response(responseBuilder().implementation(Resource.class))
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("uri")
|
||||
.description("The URI of the image")
|
||||
.required(true)
|
||||
)
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("size")
|
||||
.implementation(ThumbnailSize.class)
|
||||
.description("The size of the thumbnail")
|
||||
.required(true)
|
||||
);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
public ThumbnailService getThumbnailService() {
|
||||
return thumbnailService;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getThumbnailByUri(ServerRequest request) {
|
||||
var query = new ThumbnailQuery(request.queryParams());
|
||||
var size = query.getSize();
|
||||
var uri = query.getUri().toASCIIString();
|
||||
var listOptions = ListOptions.builder()
|
||||
.andQuery(ExtensionUtil.notDeleting())
|
||||
.andQuery(QueryFactory.equal("status.permalink", uri))
|
||||
.build();
|
||||
// query by permalink
|
||||
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
|
||||
// find the first one
|
||||
.next()
|
||||
.mapNotNull(attachment -> {
|
||||
var thumbnails = attachment.getStatus().getThumbnails();
|
||||
return thumbnails.get(size.name());
|
||||
})
|
||||
.defaultIfEmpty(uri)
|
||||
var uri = request.queryParam("uri")
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.map(URI::create);
|
||||
if (uri.isEmpty()) {
|
||||
return Mono.error(
|
||||
new ServerWebInputException("Required parameter 'uri' is missing or invalid")
|
||||
);
|
||||
}
|
||||
var size = request.queryParam("size")
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.flatMap(ThumbnailSize::optionalValueOf);
|
||||
if (size.isEmpty()) {
|
||||
return Mono.error(
|
||||
new ServerWebInputException("Required parameter 'size' is missing or invalid")
|
||||
);
|
||||
}
|
||||
return thumbnailService.get(uri.get(), size.get())
|
||||
.defaultIfEmpty(uri.get())
|
||||
.flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND)
|
||||
.location(URI.create(thumbnailLink))
|
||||
.location(thumbnailLink)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package run.halo.app.theme.finders.impl;
|
||||
|
||||
import java.net.URI;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.attachment.ThumbnailService;
|
||||
import run.halo.app.core.attachment.ThumbnailSize;
|
||||
import run.halo.app.theme.finders.Finder;
|
||||
import run.halo.app.theme.finders.ThumbnailFinder;
|
||||
|
||||
|
@ -11,9 +14,12 @@ import run.halo.app.theme.finders.ThumbnailFinder;
|
|||
@RequiredArgsConstructor
|
||||
public class ThumbnailFinderImpl implements ThumbnailFinder {
|
||||
|
||||
private final ThumbnailService thumbnailService;
|
||||
|
||||
@Override
|
||||
public Mono<String> gen(String uriStr, String size) {
|
||||
// TODO Implement me
|
||||
return Mono.just(uriStr);
|
||||
return thumbnailService.get(URI.create(uriStr), ThumbnailSize.fromName(size))
|
||||
.map(URI::toASCIIString);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,225 +0,0 @@
|
|||
package run.halo.app.core.attachment.impl;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.assertArg;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isA;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.skyscreamer.jsonassert.JSONAssert;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.core.attachment.LocalThumbnailProvider;
|
||||
import run.halo.app.core.attachment.LocalThumbnailService;
|
||||
import run.halo.app.core.attachment.ThumbnailProvider;
|
||||
import run.halo.app.core.attachment.ThumbnailSigner;
|
||||
import run.halo.app.core.attachment.ThumbnailSize;
|
||||
import run.halo.app.core.attachment.extension.Thumbnail;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.PageRequest;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalLinkProcessor;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
|
||||
/**
|
||||
* Tests for {@link ThumbnailServiceImpl}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.19.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ThumbnailServiceImplTest {
|
||||
@Mock
|
||||
private ExternalLinkProcessor externalLinkProcessor;
|
||||
|
||||
@Mock
|
||||
private ExtensionGetter extensionGetter;
|
||||
|
||||
@Mock
|
||||
private LocalThumbnailProvider localThumbnailProvider;
|
||||
|
||||
@Mock
|
||||
private LocalThumbnailService localThumbnailService;
|
||||
|
||||
@Mock
|
||||
private ReactiveExtensionClient client;
|
||||
|
||||
@InjectMocks
|
||||
private ThumbnailServiceImpl thumbnailService;
|
||||
|
||||
@Test
|
||||
void toImageUrl() {
|
||||
var link = "/test.jpg";
|
||||
when(externalLinkProcessor.processLink(link)).thenReturn("http://localhost:8090/test.jpg");
|
||||
var imageUrl = thumbnailService.toImageUrl(URI.create(link));
|
||||
assertThat(imageUrl).isPresent();
|
||||
assertThat(imageUrl.get().toString()).isEqualTo("http://localhost:8090/test.jpg");
|
||||
|
||||
var absoluteLink = "https://halo.run/test.jpg";
|
||||
imageUrl = thumbnailService.toImageUrl(URI.create(absoluteLink));
|
||||
assertThat(imageUrl).isPresent();
|
||||
assertThat(imageUrl.get().toString()).isEqualTo(absoluteLink);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateTest() {
|
||||
var uri = URI.create("http://localhost:8090/test.jpg");
|
||||
var size = ThumbnailSize.L;
|
||||
when(localThumbnailService.ensureInSiteUriIsRelative(eq(uri)))
|
||||
.thenReturn(uri);
|
||||
var imageHash = ThumbnailSigner.generateSignature(uri.toString());
|
||||
var id = Thumbnail.idIndexFunc(imageHash, size.name());
|
||||
var listOptions = ListOptions.builder()
|
||||
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
|
||||
.build();
|
||||
when(client.listBy(eq(Thumbnail.class), any(), any())).thenReturn(Mono.empty());
|
||||
|
||||
var spyThumbnailService = spy(thumbnailService);
|
||||
doReturn(Mono.empty()).when(spyThumbnailService).create(any(), any());
|
||||
|
||||
spyThumbnailService.generate(uri, size)
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
|
||||
verify(client).listBy(eq(Thumbnail.class), assertArg(options -> {
|
||||
assertThat(options.toString()).isEqualTo(listOptions.toString());
|
||||
}), isA(PageRequest.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createTest() throws MalformedURLException, URISyntaxException {
|
||||
var url = new URL("http://localhost:8090/test.jpg");
|
||||
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
|
||||
.thenReturn(Flux.just(localThumbnailProvider));
|
||||
var thumbUri = URI.create("/test-thumb.jpg");
|
||||
when(localThumbnailProvider.generate(any())).thenReturn(Mono.just(thumbUri));
|
||||
when(localThumbnailProvider.supports(any())).thenReturn(Mono.just(true));
|
||||
|
||||
var insiteUri = URI.create("/test.jpg");
|
||||
when(localThumbnailService.ensureInSiteUriIsRelative(any()))
|
||||
.thenReturn(insiteUri);
|
||||
when(client.create(any())).thenReturn(Mono.empty());
|
||||
|
||||
when(client.listBy(eq(Thumbnail.class), any(), isA(PageRequest.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
thumbnailService.create(url, ThumbnailSize.M)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(thumbUri)
|
||||
.verifyComplete();
|
||||
|
||||
thumbnailService.fetchThumbnail(url.toURI(), ThumbnailSize.M)
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
var hash = ThumbnailSigner.generateSignature(insiteUri.toString());
|
||||
|
||||
verify(client, times(2)).listBy(eq(Thumbnail.class),
|
||||
assertArg(options -> {
|
||||
var exceptOptions = ListOptions.builder()
|
||||
.fieldQuery(equal(Thumbnail.ID_INDEX,
|
||||
Thumbnail.idIndexFunc(hash, ThumbnailSize.M.name())
|
||||
))
|
||||
.build();
|
||||
assertThat(options.toString()).isEqualTo(exceptOptions.toString());
|
||||
}), isA(PageRequest.class));
|
||||
|
||||
verify(localThumbnailProvider).generate(any());
|
||||
|
||||
verify(client).create(assertArg(thumb -> {
|
||||
JSONAssert.assertEquals("""
|
||||
{
|
||||
"spec": {
|
||||
"imageSignature": "%s",
|
||||
"imageUri": "/test.jpg",
|
||||
"size": "M",
|
||||
"thumbnailUri": "/test-thumb.jpg"
|
||||
},
|
||||
"apiVersion": "storage.halo.run/v1alpha1",
|
||||
"kind": "Thumbnail",
|
||||
"metadata": {
|
||||
"generateName": "thumb-"
|
||||
}
|
||||
}
|
||||
""".formatted(hash), JsonUtils.objectToJson(thumb), true);
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createTest2() throws MalformedURLException {
|
||||
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
|
||||
.thenReturn(Flux.empty());
|
||||
|
||||
// no thumbnail provider will do nothing
|
||||
var url = new URL("http://localhost:8090/test.jpg");
|
||||
thumbnailService.create(url, ThumbnailSize.M)
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Nested
|
||||
class ThumbnailGenerateConcurrencyTest {
|
||||
|
||||
@Test
|
||||
public void concurrentThumbnailGeneration() throws InterruptedException {
|
||||
var spyThumbnailService = spy(thumbnailService);
|
||||
|
||||
URI imageUri = URI.create("http://localhost:8090/test.jpg");
|
||||
|
||||
doReturn(Mono.empty()).when(spyThumbnailService).fetchThumbnail(eq(imageUri), any());
|
||||
|
||||
var createdUri = URI.create("/test-thumb.jpg");
|
||||
doReturn(Mono.just(createdUri)).when(spyThumbnailService).create(any(), any());
|
||||
|
||||
int threadCount = 100;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
var latch = new CountDownLatch(threadCount);
|
||||
|
||||
var results = new ConcurrentLinkedQueue<Mono<URI>>();
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
results.add(spyThumbnailService.generate(imageUri, ThumbnailSize.M));
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
latch.await();
|
||||
|
||||
results.forEach(result -> {
|
||||
StepVerifier.create(result)
|
||||
.expectNext(createdUri)
|
||||
.verifyComplete();
|
||||
});
|
||||
|
||||
verify(spyThumbnailService).fetchThumbnail(eq(imageUri), eq(ThumbnailSize.M));
|
||||
verify(spyThumbnailService).create(any(), eq(ThumbnailSize.M));
|
||||
|
||||
executor.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue