mirror of https://github.com/halo-dev/halo
Optimize homepage post loading by eliminating N+1 queries for user data (#7668)
#### What type of PR is this? /kind improvement /area core /milestone 2.21.x #### What this PR does / why we need it: This PR refactors posts query to reduce database queries significantly. #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/7593 Supersedes https://github.com/halo-dev/halo/pull/7644 #### Does this PR introduce a user-facing change? ```release-note 优化首页、归档页加载速度 ```pull/7673/head
parent
27c18631e0
commit
3a50fdc4e5
|
@ -1,5 +1,6 @@
|
|||
package run.halo.app.core.user.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
@ -7,10 +8,14 @@ import run.halo.app.core.extension.User;
|
|||
|
||||
public interface UserService {
|
||||
|
||||
String GHOST_USER_NAME = "ghost";
|
||||
|
||||
Mono<User> getUser(String username);
|
||||
|
||||
Mono<User> getUserOrGhost(String username);
|
||||
|
||||
Flux<User> getUsersOrGhosts(Collection<String> names);
|
||||
|
||||
Mono<User> updatePassword(String username, String newPassword);
|
||||
|
||||
Mono<User> updateWithRawPassword(String username, String rawPassword);
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package run.halo.app.core.counter;
|
||||
|
||||
import java.util.Collection;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
|
||||
|
@ -11,5 +13,7 @@ public interface CounterService {
|
|||
|
||||
Mono<Counter> getByName(String counterName);
|
||||
|
||||
Flux<Counter> getByNames(Collection<String> names);
|
||||
|
||||
Mono<Counter> deleteByName(String counterName);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
package run.halo.app.core.counter;
|
||||
|
||||
import java.util.Collection;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
|
||||
/**
|
||||
* Counter service implementation.
|
||||
|
@ -25,6 +31,17 @@ public class CounterServiceImpl implements CounterService {
|
|||
return client.fetch(Counter.class, counterName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<Counter> getByNames(Collection<String> names) {
|
||||
if (CollectionUtils.isEmpty(names)) {
|
||||
return Flux.empty();
|
||||
}
|
||||
var options = ListOptions.builder()
|
||||
.andQuery(QueryFactory.in("metadata.name", names))
|
||||
.build();
|
||||
return client.listAll(Counter.class, options, ExtensionUtil.defaultSort());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Counter> deleteByName(String counterName) {
|
||||
return client.fetch(Counter.class, counterName)
|
||||
|
|
|
@ -5,6 +5,7 @@ import static run.halo.app.extension.index.query.QueryFactory.equal;
|
|||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
|
@ -38,6 +39,7 @@ import run.halo.app.extension.ListOptions;
|
|||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
|
@ -54,8 +56,6 @@ import run.halo.app.security.device.DeviceService;
|
|||
@RequiredArgsConstructor
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
public static final String GHOST_USER_NAME = "ghost";
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
@ -92,6 +92,19 @@ public class UserServiceImpl implements UserService {
|
|||
.switchIfEmpty(Mono.defer(() -> client.get(User.class, GHOST_USER_NAME)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<User> getUsersOrGhosts(Collection<String> names) {
|
||||
if (CollectionUtils.isEmpty(names)) {
|
||||
return Flux.empty();
|
||||
}
|
||||
var nameSet = new HashSet<>(names);
|
||||
nameSet.add(GHOST_USER_NAME);
|
||||
var options = ListOptions.builder()
|
||||
.andQuery(QueryFactory.in("metadata.name", nameSet))
|
||||
.build();
|
||||
return client.listAll(User.class, options, defaultSort());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<User> updatePassword(String username, String newPassword) {
|
||||
return getUser(username)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package run.halo.app.theme.finders;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Collection;
|
||||
import org.springframework.lang.Nullable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
@ -19,7 +19,7 @@ public interface CategoryFinder {
|
|||
|
||||
Mono<CategoryVo> getByName(String name);
|
||||
|
||||
Flux<CategoryVo> getByNames(List<String> names);
|
||||
Flux<CategoryVo> getByNames(Collection<String> names);
|
||||
|
||||
Mono<ListResult<CategoryVo>> list(@Nullable Integer page, @Nullable Integer size);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package run.halo.app.theme.finders;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Collection;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.User;
|
||||
|
@ -13,5 +13,6 @@ public interface ContributorFinder {
|
|||
|
||||
Mono<ContributorVo> getContributor(String name);
|
||||
|
||||
Flux<ContributorVo> getContributors(List<String> names);
|
||||
Flux<ContributorVo> getContributors(Collection<String> names);
|
||||
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package run.halo.app.theme.finders;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.lang.NonNull;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
|
@ -30,6 +31,8 @@ public interface PostPublicQueryService {
|
|||
*/
|
||||
Mono<ListedPostVo> convertToListedVo(@NonNull Post post);
|
||||
|
||||
Mono<List<ListedPostVo>> convertToListedVos(List<Post> posts);
|
||||
|
||||
/**
|
||||
* Converts {@link Post} to post vo and populate post content by the given snapshot name.
|
||||
* <p> This method will get post content by {@code snapshotName} and try to find
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package run.halo.app.theme.finders;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.lang.Nullable;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
@ -18,7 +19,7 @@ public interface TagFinder {
|
|||
|
||||
Mono<TagVo> getByName(String name);
|
||||
|
||||
Flux<TagVo> getByNames(List<String> names);
|
||||
Flux<TagVo> getByNames(Collection<String> names);
|
||||
|
||||
Mono<ListResult<TagVo>> list(@Nullable Integer page, @Nullable Integer size);
|
||||
|
||||
|
|
|
@ -19,15 +19,18 @@ import org.apache.commons.lang3.StringUtils;
|
|||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.CategoryService;
|
||||
import run.halo.app.core.extension.content.Category;
|
||||
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.extension.index.query.QueryFactory;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
import run.halo.app.theme.finders.CategoryFinder;
|
||||
import run.halo.app.theme.finders.Finder;
|
||||
|
@ -54,12 +57,15 @@ public class CategoryFinderImpl implements CategoryFinder {
|
|||
}
|
||||
|
||||
@Override
|
||||
public Flux<CategoryVo> getByNames(List<String> names) {
|
||||
if (names == null) {
|
||||
public Flux<CategoryVo> getByNames(Collection<String> names) {
|
||||
if (CollectionUtils.isEmpty(names)) {
|
||||
return Flux.empty();
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(this::getByName);
|
||||
var options = ListOptions.builder()
|
||||
.andQuery(QueryFactory.in("metadata.name", names))
|
||||
.build();
|
||||
return client.listAll(Category.class, options, ExtensionUtil.defaultSort())
|
||||
.map(CategoryVo::from);
|
||||
}
|
||||
|
||||
static Sort defaultSort() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package run.halo.app.theme.finders.impl;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
@ -23,16 +23,11 @@ public class ContributorFinderImpl implements ContributorFinder {
|
|||
|
||||
@Override
|
||||
public Mono<ContributorVo> getContributor(String name) {
|
||||
return userService.getUserOrGhost(name)
|
||||
.map(ContributorVo::from);
|
||||
return userService.getUserOrGhost(name).map(ContributorVo::from);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ContributorVo> getContributors(List<String> names) {
|
||||
if (names == null) {
|
||||
return Flux.empty();
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMapSequential(this::getContributor);
|
||||
public Flux<ContributorVo> getContributors(Collection<String> names) {
|
||||
return userService.getUsersOrGhosts(names).map(ContributorVo::from);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -291,7 +291,9 @@ public class PostFinderImpl implements PostFinder {
|
|||
public Flux<ListedPostVo> listAll() {
|
||||
return postPredicateResolver.getListOptions()
|
||||
.flatMapMany(listOptions -> client.listAll(Post.class, listOptions, defaultSort()))
|
||||
.flatMapSequential(postPublicQueryService::convertToListedVo);
|
||||
.collectList()
|
||||
.flatMap(postPublicQueryService::convertToListedVos)
|
||||
.flatMapMany(Flux::fromIterable);
|
||||
}
|
||||
|
||||
static int pageNullSafe(Integer page) {
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
package run.halo.app.theme.finders.impl;
|
||||
|
||||
import static java.util.Objects.requireNonNullElse;
|
||||
import static java.util.Objects.requireNonNullElseGet;
|
||||
import static run.halo.app.core.counter.MeterUtils.nameOf;
|
||||
import static run.halo.app.core.user.service.UserService.GHOST_USER_NAME;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.content.PostService;
|
||||
import run.halo.app.core.counter.CounterService;
|
||||
import run.halo.app.core.counter.MeterUtils;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
|
@ -25,6 +30,7 @@ import run.halo.app.theme.finders.ContributorFinder;
|
|||
import run.halo.app.theme.finders.PostPublicQueryService;
|
||||
import run.halo.app.theme.finders.TagFinder;
|
||||
import run.halo.app.theme.finders.vo.ContentVo;
|
||||
import run.halo.app.theme.finders.vo.ContributorVo;
|
||||
import run.halo.app.theme.finders.vo.ListedPostVo;
|
||||
import run.halo.app.theme.finders.vo.PostVo;
|
||||
import run.halo.app.theme.finders.vo.StatsVo;
|
||||
|
@ -66,16 +72,12 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
|
|||
return option;
|
||||
})
|
||||
.flatMap(listOptions -> client.listBy(Post.class, listOptions, page))
|
||||
.flatMap(list -> Flux.fromStream(list.get())
|
||||
.flatMapSequential(post -> convertToListedVo(post)
|
||||
.flatMap(postVo -> populateStats(postVo)
|
||||
.doOnNext(postVo::setStats).thenReturn(postVo)
|
||||
.flatMap(list -> convertToListedVos(list.getItems())
|
||||
.map(
|
||||
postVos -> new ListResult<>(
|
||||
list.getPage(), list.getSize(), list.getTotal(), postVos
|
||||
)
|
||||
)
|
||||
.collectList()
|
||||
.map(postVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
|
||||
postVos)
|
||||
)
|
||||
)
|
||||
.defaultIfEmpty(ListResult.emptyResult());
|
||||
}
|
||||
|
@ -128,6 +130,97 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
|
|||
.defaultIfEmpty(postVo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<ListedPostVo>> convertToListedVos(List<Post> posts) {
|
||||
var counterNames = new HashSet<String>(posts.size());
|
||||
var userNames = new HashSet<String>();
|
||||
var tagNames = new HashSet<String>();
|
||||
var categoryNames = new HashSet<String>();
|
||||
posts.forEach(post -> {
|
||||
counterNames.add(nameOf(Post.class, post.getMetadata().getName()));
|
||||
var spec = post.getSpec();
|
||||
userNames.add(spec.getOwner());
|
||||
var status = post.getStatus();
|
||||
if (status != null && status.getContributors() != null) {
|
||||
userNames.addAll(status.getContributors());
|
||||
}
|
||||
if (spec.getTags() != null) {
|
||||
tagNames.addAll(spec.getTags());
|
||||
}
|
||||
if (spec.getCategories() != null) {
|
||||
categoryNames.addAll(spec.getCategories());
|
||||
}
|
||||
});
|
||||
|
||||
var getCounters = counterService.getByNames(counterNames)
|
||||
.collectMap(counter -> counter.getMetadata().getName());
|
||||
var getContributors = contributorFinder.getContributors(userNames)
|
||||
.collectMap(ContributorVo::getName);
|
||||
var getTags = tagFinder.getByNames(tagNames)
|
||||
.collectMap(tagVo -> tagVo.getMetadata().getName());
|
||||
var getCategories = categoryFinder.getByNames(categoryNames)
|
||||
.collectMap(categoryVo -> categoryVo.getMetadata().getName());
|
||||
|
||||
return Mono.zip(getCounters, getContributors, getTags, getCategories)
|
||||
.map(tuple -> {
|
||||
var counters = tuple.getT1();
|
||||
var contributors = tuple.getT2();
|
||||
var tags = tuple.getT3();
|
||||
var categories = tuple.getT4();
|
||||
return posts.stream()
|
||||
.map(post -> {
|
||||
var vo = ListedPostVo.from(post);
|
||||
vo.setCategories(List.of());
|
||||
vo.setTags(List.of());
|
||||
vo.setContributors(List.of());
|
||||
|
||||
var spec = post.getSpec();
|
||||
var status = post.getStatus();
|
||||
var ghost = requireNonNullElseGet(
|
||||
contributors.get(GHOST_USER_NAME), ContributorVo::ghost
|
||||
);
|
||||
vo.setOwner(requireNonNullElse(contributors.get(spec.getOwner()), ghost));
|
||||
if (status != null && !CollectionUtils.isEmpty(status.getContributors())) {
|
||||
vo.setContributors(status.getContributors()
|
||||
.stream()
|
||||
.map(name -> requireNonNullElse(contributors.get(name), ghost))
|
||||
.toList());
|
||||
}
|
||||
|
||||
if (!CollectionUtils.isEmpty(spec.getTags())) {
|
||||
vo.setTags(spec.getTags()
|
||||
.stream()
|
||||
.map(tags::get)
|
||||
.filter(Objects::nonNull)
|
||||
.toList());
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(spec.getCategories())) {
|
||||
vo.setCategories(spec.getCategories()
|
||||
.stream()
|
||||
.map(categories::get)
|
||||
.filter(Objects::nonNull)
|
||||
.toList());
|
||||
}
|
||||
|
||||
var counterName = nameOf(Post.class, post.getMetadata().getName());
|
||||
var counter = counters.get(counterName);
|
||||
if (counter != null) {
|
||||
vo.setStats(StatsVo.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.comment(counter.getApprovedComment())
|
||||
.build()
|
||||
);
|
||||
} else {
|
||||
vo.setStats(StatsVo.empty());
|
||||
}
|
||||
|
||||
return vo;
|
||||
})
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PostVo> convertToVo(Post post, String snapshotName) {
|
||||
final String baseSnapshotName = post.getSpec().getBaseSnapshot();
|
||||
|
@ -177,7 +270,7 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
|
|||
}
|
||||
|
||||
private <T extends ListedPostVo> Mono<StatsVo> populateStats(T postVo) {
|
||||
return counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata()
|
||||
return counterService.getByName(nameOf(Post.class, postVo.getMetadata()
|
||||
.getName())
|
||||
)
|
||||
.map(counter -> StatsVo.builder()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package run.halo.app.theme.finders.impl;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
@ -9,11 +10,13 @@ import org.springframework.util.CollectionUtils;
|
|||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Tag;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.PageRequest;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.app.theme.finders.Finder;
|
||||
import run.halo.app.theme.finders.TagFinder;
|
||||
import run.halo.app.theme.finders.vo.TagVo;
|
||||
|
@ -43,9 +46,15 @@ public class TagFinderImpl implements TagFinder {
|
|||
}
|
||||
|
||||
@Override
|
||||
public Flux<TagVo> getByNames(List<String> names) {
|
||||
return Flux.fromIterable(names)
|
||||
.flatMapSequential(this::getByName);
|
||||
public Flux<TagVo> getByNames(Collection<String> names) {
|
||||
if (CollectionUtils.isEmpty(names)) {
|
||||
return Flux.empty();
|
||||
}
|
||||
var options = ListOptions.builder()
|
||||
.andQuery(QueryFactory.in("metadata.name", names))
|
||||
.build();
|
||||
return client.listAll(Tag.class, options, ExtensionUtil.defaultSort())
|
||||
.map(TagVo::from);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -4,6 +4,8 @@ import lombok.Builder;
|
|||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.user.service.UserService;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.MetadataOperator;
|
||||
|
||||
/**
|
||||
|
@ -46,4 +48,22 @@ public class ContributorVo implements ExtensionVoOperator {
|
|||
.metadata(user.getMetadata())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ghost contributor.
|
||||
*
|
||||
* @return a ghost contributor value object
|
||||
*/
|
||||
public static ContributorVo ghost() {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(UserService.GHOST_USER_NAME);
|
||||
return builder()
|
||||
.name("ghost")
|
||||
.displayName("Ghost")
|
||||
// .avatar("/images/ghost.png")
|
||||
.bio("A ghost user.")
|
||||
.permalink("/authors/ghost")
|
||||
.metadata(metadata)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue