mirror of https://github.com/halo-dev/halo
refactor: metrics to fix post visits cannot be migrated (#2870)
#### What type of PR is this? /kind improvement /area core #### What this PR does / why we need it: 重构访问量统计逻辑 1. 去掉了访问量总数存储在 Meter Counter 中的逻辑,因为迁移时是直接像 Counter 自定义模型创建数据,而文章被访问时是存储在 Meter Counter 后定时同步到数据库,这就存在双向同步问题且都有新数据无法知道该如何合并数据。 2. 目前访问时会发送一个事件,当得到事件后会缓存在队列中,每隔一分钟将增量更新到数据库中 3. 评论统计也去掉了 Meter Counter 改为事件队列处理 4. 如果后续要暴露 Metrics 应该使用 Gauge 监控 Counter 自定义模型 5. Counter 自定义模型的查询优化后续可以使用 Indexer 或者加缓存来实现而非将 Meter Counter 当作缓存 #### Which issue(s) this PR fixes: Fixes #2820 #### Special notes for your reviewer: 1. 测试迁移导入看文章访问量是否正确 2. 创建评论及回复观察未读回复数量、评论回复数、最新回复时间是否正确 3. 多创建一些回复然后删除评论,看是否正确删除 /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 重构访问量统计逻辑,修复文章visits无法迁移的问题 ```pull/2936/head^2
parent
cfb66b0faa
commit
a9a65dd408
|
@ -20,4 +20,13 @@ public class Stats {
|
|||
Integer totalComment;
|
||||
|
||||
Integer approvedComment;
|
||||
|
||||
public static Stats empty() {
|
||||
return Stats.builder()
|
||||
.visit(0)
|
||||
.upvote(0)
|
||||
.totalComment(0)
|
||||
.approvedComment(0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,6 @@ import run.halo.app.content.PostRequest;
|
|||
import run.halo.app.content.PostService;
|
||||
import run.halo.app.content.PostSorter;
|
||||
import run.halo.app.content.Stats;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.content.Category;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
|
@ -76,17 +75,18 @@ public class PostServiceImpl implements PostService {
|
|||
);
|
||||
}
|
||||
|
||||
Stats fetchStats(Post post) {
|
||||
Mono<Stats> fetchStats(Post post) {
|
||||
Assert.notNull(post, "The post must not be null.");
|
||||
String name = post.getMetadata().getName();
|
||||
Counter counter =
|
||||
counterService.getByName(MeterUtils.nameOf(Post.class, name));
|
||||
return Stats.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.totalComment(counter.getTotalComment())
|
||||
.approvedComment(counter.getApprovedComment())
|
||||
.build();
|
||||
return counterService.getByName(MeterUtils.nameOf(Post.class, name))
|
||||
.map(counter -> Stats.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.totalComment(counter.getTotalComment())
|
||||
.approvedComment(counter.getApprovedComment())
|
||||
.build()
|
||||
)
|
||||
.defaultIfEmpty(Stats.empty());
|
||||
}
|
||||
|
||||
Predicate<Post> postListPredicate(PostQuery query) {
|
||||
|
@ -154,9 +154,12 @@ public class PostServiceImpl implements PostService {
|
|||
.map(p -> {
|
||||
ListedPost listedPost = new ListedPost();
|
||||
listedPost.setPost(p);
|
||||
listedPost.setStats(fetchStats(post));
|
||||
return listedPost;
|
||||
})
|
||||
.flatMap(lp -> fetchStats(post)
|
||||
.doOnNext(lp::setStats)
|
||||
.thenReturn(lp)
|
||||
)
|
||||
.flatMap(lp -> setTags(post.getSpec().getTags(), lp))
|
||||
.flatMap(lp -> setCategories(post.getSpec().getCategories(), lp))
|
||||
.flatMap(lp -> setContributors(post.getStatus().getContributors(), lp))
|
||||
|
|
|
@ -33,7 +33,6 @@ import run.halo.app.content.SinglePageRequest;
|
|||
import run.halo.app.content.SinglePageService;
|
||||
import run.halo.app.content.SinglePageSorter;
|
||||
import run.halo.app.content.Stats;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.SinglePage;
|
||||
|
@ -213,9 +212,12 @@ public class SinglePageServiceImpl implements SinglePageService {
|
|||
.map(sp -> {
|
||||
ListedSinglePage listedSinglePage = new ListedSinglePage();
|
||||
listedSinglePage.setPage(singlePage);
|
||||
listedSinglePage.setStats(fetchStats(singlePage));
|
||||
return listedSinglePage;
|
||||
})
|
||||
.flatMap(sp -> fetchStats(singlePage)
|
||||
.doOnNext(sp::setStats)
|
||||
.thenReturn(sp)
|
||||
)
|
||||
.flatMap(lsp ->
|
||||
setContributors(singlePage.getStatusOrDefault().getContributors(), lsp))
|
||||
.flatMap(lsp -> setOwner(singlePage.getSpec().getOwner(), lsp));
|
||||
|
@ -243,17 +245,18 @@ public class SinglePageServiceImpl implements SinglePageService {
|
|||
.thenReturn(page);
|
||||
}
|
||||
|
||||
Stats fetchStats(SinglePage singlePage) {
|
||||
Mono<Stats> fetchStats(SinglePage singlePage) {
|
||||
Assert.notNull(singlePage, "The singlePage must not be null.");
|
||||
String name = singlePage.getMetadata().getName();
|
||||
Counter counter =
|
||||
counterService.getByName(MeterUtils.nameOf(SinglePage.class, name));
|
||||
return Stats.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.totalComment(counter.getTotalComment())
|
||||
.approvedComment(counter.getApprovedComment())
|
||||
.build();
|
||||
return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name))
|
||||
.map(counter -> Stats.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.totalComment(counter.getTotalComment())
|
||||
.approvedComment(counter.getApprovedComment())
|
||||
.build()
|
||||
)
|
||||
.defaultIfEmpty(Stats.empty());
|
||||
}
|
||||
|
||||
private Flux<Contributor> listContributors(List<String> usernames) {
|
||||
|
|
|
@ -6,6 +6,7 @@ import lombok.Data;
|
|||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.metrics.MeterUtils;
|
||||
|
||||
/**
|
||||
|
@ -61,4 +62,15 @@ public class Counter extends AbstractExtension {
|
|||
this.totalComment = 0;
|
||||
this.approvedComment = 0;
|
||||
}
|
||||
|
||||
public static Counter emptyCounter(String name) {
|
||||
Counter counter = new Counter();
|
||||
counter.setMetadata(new Metadata());
|
||||
counter.getMetadata().setName(name);
|
||||
counter.setUpvote(0);
|
||||
counter.setTotalComment(0);
|
||||
counter.setApprovedComment(0);
|
||||
counter.setVisit(0);
|
||||
return counter;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,11 +3,13 @@ package run.halo.app.core.extension.content;
|
|||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.extension.Ref;
|
||||
|
@ -116,8 +118,22 @@ public class Comment extends AbstractExtension {
|
|||
|
||||
private Integer unreadReplyCount;
|
||||
|
||||
public boolean getHasNewReply() {
|
||||
return unreadReplyCount != null && unreadReplyCount > 0;
|
||||
private Boolean hasNewReply;
|
||||
}
|
||||
|
||||
public static int getUnreadReplyCount(List<Reply> replies, Instant lastReadTime) {
|
||||
if (CollectionUtils.isEmpty(replies)) {
|
||||
return 0;
|
||||
}
|
||||
long unreadReplyCount = replies.stream()
|
||||
.filter(existingReply -> {
|
||||
if (lastReadTime == null) {
|
||||
return true;
|
||||
}
|
||||
return existingReply.getMetadata().getCreationTimestamp()
|
||||
.isAfter(lastReadTime);
|
||||
})
|
||||
.count();
|
||||
return (int) unreadReplyCount;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,5 @@ public enum Constant {
|
|||
public static final String GROUP = "content.halo.run";
|
||||
public static final String VERSION = "v1alpha1";
|
||||
|
||||
public static final String LAST_READ_TIME_ANNO = "content.halo.run/last-read-time";
|
||||
}
|
||||
|
|
|
@ -4,11 +4,11 @@ import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder
|
|||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
|
@ -16,6 +16,9 @@ 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.event.post.DownvotedEvent;
|
||||
import run.halo.app.event.post.UpvotedEvent;
|
||||
import run.halo.app.event.post.VisitedEvent;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.infra.utils.HaloUtils;
|
||||
import run.halo.app.infra.utils.IpAddressUtils;
|
||||
|
@ -28,17 +31,13 @@ import run.halo.app.metrics.VisitLogWriter;
|
|||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@Component
|
||||
public class TrackerEndpoint implements CustomEndpoint {
|
||||
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final VisitLogWriter visitLogWriter;
|
||||
|
||||
public TrackerEndpoint(MeterRegistry meterRegistry, VisitLogWriter visitLogWriter) {
|
||||
this.meterRegistry = meterRegistry;
|
||||
this.visitLogWriter = visitLogWriter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = "api.halo.run/v1alpha1/Tracker";
|
||||
|
@ -55,7 +54,7 @@ public class TrackerEndpoint implements CustomEndpoint {
|
|||
.implementation(CounterRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Integer.class))
|
||||
.implementation(Void.class))
|
||||
)
|
||||
.POST("trackers/upvote", this::upvote,
|
||||
builder -> builder.operationId("upvote")
|
||||
|
@ -69,7 +68,7 @@ public class TrackerEndpoint implements CustomEndpoint {
|
|||
.implementation(VoteRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Integer.class))
|
||||
.implementation(Void.class))
|
||||
)
|
||||
.POST("trackers/downvote", this::downvote,
|
||||
builder -> builder.operationId("downvote")
|
||||
|
@ -83,7 +82,7 @@ public class TrackerEndpoint implements CustomEndpoint {
|
|||
.implementation(VoteRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Integer.class))
|
||||
.implementation(Void.class))
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
@ -92,50 +91,36 @@ public class TrackerEndpoint implements CustomEndpoint {
|
|||
return request.bodyToMono(CounterRequest.class)
|
||||
.switchIfEmpty(
|
||||
Mono.error(new IllegalArgumentException("Counter request body must not be empty")))
|
||||
.map(counterRequest -> {
|
||||
String counterName =
|
||||
MeterUtils.nameOf(counterRequest.group(), counterRequest.plural(),
|
||||
counterRequest.name());
|
||||
.doOnNext(counterRequest -> {
|
||||
eventPublisher.publishEvent(new VisitedEvent(this, counterRequest.group(),
|
||||
counterRequest.name(), counterRequest.plural()));
|
||||
|
||||
Counter counter = MeterUtils.visitCounter(meterRegistry, counterName);
|
||||
counter.increment();
|
||||
// async write visit log
|
||||
writeVisitLog(request, counterRequest);
|
||||
return (int) counter.count();
|
||||
})
|
||||
.flatMap(count -> ServerResponse.ok().bodyValue(count));
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> upvote(ServerRequest request) {
|
||||
return request.bodyToMono(VoteRequest.class)
|
||||
.switchIfEmpty(
|
||||
Mono.error(new IllegalArgumentException("Upvote request body must not be empty")))
|
||||
.map(voteRequest -> {
|
||||
String counterName =
|
||||
MeterUtils.nameOf(voteRequest.group(), voteRequest.plural(),
|
||||
voteRequest.name());
|
||||
|
||||
Counter counter = MeterUtils.upvoteCounter(meterRegistry, counterName);
|
||||
counter.increment();
|
||||
return (int) counter.count();
|
||||
.doOnNext(voteRequest -> {
|
||||
eventPublisher.publishEvent(new UpvotedEvent(this, voteRequest.group(),
|
||||
voteRequest.name(), voteRequest.plural()));
|
||||
})
|
||||
.flatMap(count -> ServerResponse.ok().bodyValue(count));
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> downvote(ServerRequest request) {
|
||||
return request.bodyToMono(VoteRequest.class)
|
||||
.switchIfEmpty(
|
||||
Mono.error(new IllegalArgumentException("Downvote request body must not be empty")))
|
||||
.map(voteRequest -> {
|
||||
String counterName =
|
||||
MeterUtils.nameOf(voteRequest.group(), voteRequest.plural(),
|
||||
voteRequest.name());
|
||||
|
||||
Counter counter = MeterUtils.downvoteCounter(meterRegistry, counterName);
|
||||
counter.increment();
|
||||
return (int) counter.count();
|
||||
.doOnNext(voteRequest -> {
|
||||
eventPublisher.publishEvent(new DownvotedEvent(this, voteRequest.group(),
|
||||
voteRequest.name(), voteRequest.plural()));
|
||||
})
|
||||
.flatMap(count -> ServerResponse.ok().bodyValue(count));
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
public record VoteRequest(String group, String plural, String name) {
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
package run.halo.app.core.extension.reconciler;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
|
@ -12,11 +9,15 @@ import java.util.Objects;
|
|||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Constant;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
import run.halo.app.extension.Ref;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
|
@ -36,30 +37,26 @@ import run.halo.app.metrics.MeterUtils;
|
|||
public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
public static final String FINALIZER_NAME = "comment-protection";
|
||||
private final ExtensionClient client;
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final SchemeManager schemeManager;
|
||||
|
||||
public CommentReconciler(ExtensionClient client, MeterRegistry meterRegistry,
|
||||
SchemeManager schemeManager) {
|
||||
public CommentReconciler(ExtensionClient client, SchemeManager schemeManager) {
|
||||
this.client = client;
|
||||
this.meterRegistry = meterRegistry;
|
||||
this.schemeManager = schemeManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
return client.fetch(Comment.class, request.name())
|
||||
.map(comment -> {
|
||||
client.fetch(Comment.class, request.name())
|
||||
.ifPresent(comment -> {
|
||||
if (isDeleted(comment)) {
|
||||
cleanUpResourcesAndRemoveFinalizer(request.name());
|
||||
return new Result(false, null);
|
||||
return;
|
||||
}
|
||||
addFinalizerIfNecessary(comment);
|
||||
reconcileStatus(request.name());
|
||||
reconcileCommentCount();
|
||||
return new Result(true, Duration.ofMinutes(1));
|
||||
})
|
||||
.orElseGet(() -> new Result(false, null));
|
||||
updateCommentCounter();
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -73,38 +70,6 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||
return comment.getMetadata().getDeletionTimestamp() != null;
|
||||
}
|
||||
|
||||
private void reconcileStatus(String name) {
|
||||
client.fetch(Comment.class, name).ifPresent(comment -> {
|
||||
final Comment oldComment = JsonUtils.deepCopy(comment);
|
||||
|
||||
List<Reply> replies = client.list(Reply.class,
|
||||
reply -> name.equals(reply.getSpec().getCommentName()),
|
||||
defaultReplyComparator());
|
||||
// calculate reply count
|
||||
comment.getStatusOrDefault().setReplyCount(replies.size());
|
||||
// calculate last reply time
|
||||
if (!replies.isEmpty()) {
|
||||
Instant lastReplyTime = replies.get(0).getMetadata().getCreationTimestamp();
|
||||
comment.getStatusOrDefault().setLastReplyTime(lastReplyTime);
|
||||
}
|
||||
// calculate unread reply count
|
||||
Instant lastReadTime = comment.getSpec().getLastReadTime();
|
||||
long unreadReplyCount = replies.stream()
|
||||
.filter(reply -> {
|
||||
if (lastReadTime == null) {
|
||||
return true;
|
||||
}
|
||||
return reply.getMetadata().getCreationTimestamp().isAfter(lastReadTime);
|
||||
})
|
||||
.count();
|
||||
comment.getStatusOrDefault().setUnreadReplyCount((int) unreadReplyCount);
|
||||
|
||||
if (!oldComment.equals(comment)) {
|
||||
client.update(comment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void addFinalizerIfNecessary(Comment oldComment) {
|
||||
Set<String> finalizers = oldComment.getMetadata().getFinalizers();
|
||||
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
|
||||
|
@ -122,7 +87,42 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||
});
|
||||
}
|
||||
|
||||
private void reconcileCommentCount() {
|
||||
private void reconcileStatus(String name) {
|
||||
client.fetch(Comment.class, name).ifPresent(comment -> {
|
||||
Comment oldComment = JsonUtils.deepCopy(comment);
|
||||
Comment.CommentStatus status = comment.getStatusOrDefault();
|
||||
status.setHasNewReply(ObjectUtils.defaultIfNull(status.getUnreadReplyCount(), 0) > 0);
|
||||
updateUnReplyCountIfNecessary(comment);
|
||||
if (!oldComment.equals(comment)) {
|
||||
client.update(comment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateUnReplyCountIfNecessary(Comment comment) {
|
||||
Instant lastReadTime = comment.getSpec().getLastReadTime();
|
||||
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(comment);
|
||||
String lastReadTimeAnno = annotations.get(Constant.LAST_READ_TIME_ANNO);
|
||||
if (lastReadTime != null && lastReadTime.toString().equals(lastReadTimeAnno)) {
|
||||
return;
|
||||
}
|
||||
// spec.lastReadTime is null or not equal to annotation.lastReadTime
|
||||
String commentName = comment.getMetadata().getName();
|
||||
List<Reply> replies = client.list(Reply.class,
|
||||
reply -> commentName.equals(reply.getSpec().getCommentName())
|
||||
&& reply.getMetadata().getDeletionTimestamp() == null,
|
||||
defaultReplyComparator());
|
||||
|
||||
// calculate unread reply count
|
||||
comment.getStatusOrDefault()
|
||||
.setUnreadReplyCount(Comment.getUnreadReplyCount(replies, lastReadTime));
|
||||
// handled flag
|
||||
if (lastReadTime != null) {
|
||||
annotations.put(Constant.LAST_READ_TIME_ANNO, lastReadTime.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateCommentCounter() {
|
||||
Map<Ref, List<RefCommentTuple>> map = client.list(Comment.class, null, null)
|
||||
.stream()
|
||||
.map(comment -> {
|
||||
|
@ -148,38 +148,20 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||
schemeManager.fetch(groupVersionKind).ifPresent(scheme -> {
|
||||
String counterName = MeterUtils.nameOf(ref.getGroup(), scheme.plural(),
|
||||
ref.getName());
|
||||
// meter for total comment count
|
||||
calcTotalComments(totalCount, counterName);
|
||||
// meter for approved comment count
|
||||
calcApprovedComments(approvedTotalCount, counterName);
|
||||
client.fetch(Counter.class, counterName).ifPresentOrElse(counter -> {
|
||||
counter.setTotalComment(totalCount);
|
||||
counter.setApprovedComment((int) approvedTotalCount);
|
||||
client.update(counter);
|
||||
}, () -> {
|
||||
Counter counter = Counter.emptyCounter(counterName);
|
||||
counter.setTotalComment(totalCount);
|
||||
counter.setApprovedComment((int) approvedTotalCount);
|
||||
client.create(counter);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void calcTotalComments(int totalCount, String counterName) {
|
||||
Counter totalCommentCounter =
|
||||
MeterUtils.totalCommentCounter(meterRegistry, counterName);
|
||||
double totalCountMeter = totalCommentCounter.count();
|
||||
double totalIncrement = totalCount - totalCountMeter;
|
||||
if (totalCountMeter + totalIncrement >= 0) {
|
||||
totalCommentCounter.increment(totalIncrement);
|
||||
} else {
|
||||
totalCommentCounter.increment(totalCountMeter * -1);
|
||||
}
|
||||
}
|
||||
|
||||
private void calcApprovedComments(long approvedTotalCount, String counterName) {
|
||||
Counter approvedCommentCounter =
|
||||
MeterUtils.approvedCommentCounter(meterRegistry, counterName);
|
||||
double approvedComments = approvedCommentCounter.count();
|
||||
double increment = approvedTotalCount - approvedCommentCounter.count();
|
||||
if (approvedComments + increment >= 0) {
|
||||
approvedCommentCounter.increment(increment);
|
||||
} else {
|
||||
approvedCommentCounter.increment(approvedComments * -1);
|
||||
}
|
||||
}
|
||||
|
||||
record RefCommentTuple(Ref ref, String name, boolean approved) {
|
||||
}
|
||||
|
||||
|
@ -200,18 +182,7 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||
null)
|
||||
.forEach(client::delete);
|
||||
// decrement total comment count
|
||||
Ref subjectRef = comment.getSpec().getSubjectRef();
|
||||
GroupVersionKind groupVersionKind = groupVersionKind(subjectRef);
|
||||
if (groupVersionKind == null) {
|
||||
return;
|
||||
}
|
||||
schemeManager.fetch(groupVersionKind)
|
||||
.ifPresent(scheme -> {
|
||||
String counterName = MeterUtils.nameOf(subjectRef.getGroup(), scheme.plural(),
|
||||
subjectRef.getName());
|
||||
MeterUtils.totalCommentCounter(meterRegistry, counterName)
|
||||
.increment(-1);
|
||||
});
|
||||
updateCommentCounter();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
package run.halo.app.core.extension.reconciler;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.event.post.ReplyCreatedEvent;
|
||||
import run.halo.app.event.post.ReplyDeletedEvent;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
|
||||
/**
|
||||
* Reconciler for {@link Reply}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
protected static final String FINALIZER_NAME = "reply-protection";
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(Reply.class, request.name())
|
||||
.ifPresent(reply -> {
|
||||
if (reply.getMetadata().getDeletionTimestamp() != null) {
|
||||
cleanUpResourcesAndRemoveFinalizer(request.name());
|
||||
return;
|
||||
}
|
||||
|
||||
if (addFinalizerIfNecessary(reply)) {
|
||||
// on reply created
|
||||
eventPublisher.publishEvent(new ReplyCreatedEvent(this, reply));
|
||||
}
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
private void cleanUpResourcesAndRemoveFinalizer(String replyName) {
|
||||
client.fetch(Reply.class, replyName).ifPresent(reply -> {
|
||||
if (reply.getMetadata().getFinalizers() != null) {
|
||||
reply.getMetadata().getFinalizers().remove(FINALIZER_NAME);
|
||||
}
|
||||
client.update(reply);
|
||||
|
||||
// on reply removed
|
||||
eventPublisher.publishEvent(new ReplyDeletedEvent(this, reply));
|
||||
});
|
||||
}
|
||||
|
||||
private boolean addFinalizerIfNecessary(Reply oldReply) {
|
||||
Set<String> finalizers = oldReply.getMetadata().getFinalizers();
|
||||
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
|
||||
return false;
|
||||
}
|
||||
client.fetch(Reply.class, oldReply.getMetadata().getName())
|
||||
.ifPresent(reply -> {
|
||||
Set<String> newFinalizers = reply.getMetadata().getFinalizers();
|
||||
if (newFinalizers == null) {
|
||||
newFinalizers = new HashSet<>();
|
||||
reply.getMetadata().setFinalizers(newFinalizers);
|
||||
}
|
||||
newFinalizers.add(FINALIZER_NAME);
|
||||
client.update(reply);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Reply())
|
||||
.build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
/**
|
||||
* Downvote event.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class DownvotedEvent extends VotedEvent {
|
||||
|
||||
public DownvotedEvent(Object source, String group, String name, String plural) {
|
||||
super(source, group, name, plural);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ReplyCreatedEvent extends ReplyEvent {
|
||||
|
||||
public ReplyCreatedEvent(Object source, Reply reply) {
|
||||
super(source, reply);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
|
||||
public class ReplyDeletedEvent extends ReplyEvent {
|
||||
|
||||
public ReplyDeletedEvent(Object source, Reply reply) {
|
||||
super(source, reply);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public abstract class ReplyEvent extends ApplicationEvent {
|
||||
private final Reply reply;
|
||||
|
||||
public ReplyEvent(Object source, Reply reply) {
|
||||
super(source);
|
||||
this.reply = reply;
|
||||
}
|
||||
|
||||
public Reply getReply() {
|
||||
return reply;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
/**
|
||||
* Upvote event.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class UpvotedEvent extends VotedEvent {
|
||||
|
||||
public UpvotedEvent(Object source, String group, String name, String plural) {
|
||||
super(source, group, name, plural);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Getter
|
||||
public class VisitedEvent extends ApplicationEvent {
|
||||
private final String group;
|
||||
private final String name;
|
||||
private final String plural;
|
||||
|
||||
public VisitedEvent(Object source, String group, String name, String plural) {
|
||||
super(source);
|
||||
this.group = group;
|
||||
this.name = name;
|
||||
this.plural = plural;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Getter
|
||||
public abstract class VotedEvent extends ApplicationEvent {
|
||||
private final String group;
|
||||
private final String name;
|
||||
private final String plural;
|
||||
|
||||
public VotedEvent(Object source, String group, String name, String plural) {
|
||||
super(source);
|
||||
this.group = group;
|
||||
this.name = name;
|
||||
this.plural = plural;
|
||||
}
|
||||
}
|
|
@ -1,157 +0,0 @@
|
|||
package run.halo.app.metrics;
|
||||
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* Counter meter handler for {@link Counter}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CounterMeterHandler implements SmartLifecycle {
|
||||
private volatile boolean started = false;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final MeterRegistry meterRegistry;
|
||||
|
||||
public CounterMeterHandler(ReactiveExtensionClient client, MeterRegistry meterRegistry) {
|
||||
this.client = client;
|
||||
this.meterRegistry = meterRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize counter meters from {@link Counter}.
|
||||
*
|
||||
* @param event application ready event
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public Mono<Void> onApplicationReady(ApplicationReadyEvent event) {
|
||||
return client.list(Counter.class, null, null)
|
||||
.map(counter -> {
|
||||
String name = counter.getMetadata().getName();
|
||||
// visit counter
|
||||
io.micrometer.core.instrument.Counter visitCounter =
|
||||
MeterUtils.visitCounter(meterRegistry, name);
|
||||
visitCounter.increment(nullSafe(counter.getVisit()));
|
||||
|
||||
// upvote counter
|
||||
io.micrometer.core.instrument.Counter upvoteCounter =
|
||||
MeterUtils.upvoteCounter(meterRegistry, name);
|
||||
upvoteCounter.increment(nullSafe(counter.getUpvote()));
|
||||
|
||||
// downvote counter
|
||||
io.micrometer.core.instrument.Counter downvoteCounter =
|
||||
MeterUtils.downvoteCounter(meterRegistry, name);
|
||||
downvoteCounter.increment(nullSafe(counter.getDownvote()));
|
||||
|
||||
// total comment counter
|
||||
io.micrometer.core.instrument.Counter totalCommentCounter =
|
||||
MeterUtils.totalCommentCounter(meterRegistry, name);
|
||||
totalCommentCounter.increment(nullSafe(counter.getTotalComment()));
|
||||
|
||||
// approved comment counter
|
||||
io.micrometer.core.instrument.Counter approvedCommentCounter =
|
||||
MeterUtils.approvedCommentCounter(meterRegistry, name);
|
||||
approvedCommentCounter.increment(nullSafe(counter.getApprovedComment()));
|
||||
return counter;
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
int nullSafe(Integer value) {
|
||||
return Objects.requireNonNullElse(value, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize memory counter meter to the database every minute.
|
||||
*/
|
||||
@Scheduled(cron = "0 0/1 * * * ?")
|
||||
public void counterPersistenceTask() {
|
||||
log.debug("Regularly synchronize counter meters to the database.");
|
||||
save().block();
|
||||
}
|
||||
|
||||
Mono<Void> save() {
|
||||
Map<String, List<Meter>> nameMeters = meterRegistry.getMeters().stream()
|
||||
.filter(meter -> meter instanceof io.micrometer.core.instrument.Counter)
|
||||
.filter(counter -> {
|
||||
Meter.Id id = counter.getId();
|
||||
return id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey()) != null;
|
||||
})
|
||||
.collect(Collectors.groupingBy(meter -> meter.getId().getName()));
|
||||
Stream<Mono<Counter>> monoStream = nameMeters.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String name = entry.getKey();
|
||||
List<Meter> meters = entry.getValue();
|
||||
return client.fetch(Counter.class, name)
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
Counter counter = emptyCounter(name);
|
||||
return client.create(counter);
|
||||
}))
|
||||
.flatMap(counter -> {
|
||||
Counter oldCounter = JsonUtils.deepCopy(counter);
|
||||
counter.populateFrom(meters);
|
||||
if (oldCounter.equals(counter)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.just(counter);
|
||||
})
|
||||
.flatMap(client::update);
|
||||
});
|
||||
return Flux.fromStream(monoStream)
|
||||
.flatMap(Function.identity())
|
||||
.then();
|
||||
}
|
||||
|
||||
static Counter emptyCounter(String name) {
|
||||
Counter counter = new Counter();
|
||||
counter.setMetadata(new Metadata());
|
||||
counter.getMetadata().setName(name);
|
||||
counter.setUpvote(0);
|
||||
counter.setTotalComment(0);
|
||||
counter.setApprovedComment(0);
|
||||
counter.setVisit(0);
|
||||
return counter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
log.debug("Persist counter meters to database before destroy...");
|
||||
try {
|
||||
save().block();
|
||||
} catch (Exception e) {
|
||||
log.error("Persist counter meters to database failed.", e);
|
||||
}
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return started;
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ import run.halo.app.core.extension.Counter;
|
|||
*/
|
||||
public interface CounterService {
|
||||
|
||||
Counter getByName(String counterName);
|
||||
Mono<Counter> getByName(String counterName);
|
||||
|
||||
Mono<Counter> deleteByName(String counterName);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
package run.halo.app.metrics;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import java.util.Collection;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
/**
|
||||
|
@ -18,44 +14,20 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
|||
@Service
|
||||
public class CounterServiceImpl implements CounterService {
|
||||
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
public CounterServiceImpl(MeterRegistry meterRegistry, ReactiveExtensionClient client) {
|
||||
this.meterRegistry = meterRegistry;
|
||||
public CounterServiceImpl(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Counter getByName(String counterName) {
|
||||
Collection<io.micrometer.core.instrument.Counter> counters =
|
||||
findCounters(counterName);
|
||||
|
||||
Counter counter = emptyCounter(counterName);
|
||||
counter.populateFrom(counters);
|
||||
return counter;
|
||||
}
|
||||
|
||||
private Collection<io.micrometer.core.instrument.Counter> findCounters(String counterName) {
|
||||
Tag commonTag = MeterUtils.METRICS_COMMON_TAG;
|
||||
return meterRegistry.find(counterName)
|
||||
.tag(commonTag.getKey(),
|
||||
valueMatch -> commonTag.getValue().equals(valueMatch))
|
||||
.counters();
|
||||
public Mono<Counter> getByName(String counterName) {
|
||||
return client.fetch(Counter.class, counterName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Counter> deleteByName(String counterName) {
|
||||
return client.fetch(Counter.class, counterName)
|
||||
.flatMap(counter -> client.delete(counter)
|
||||
.doOnNext(deleted -> findCounters(counterName).forEach(meterRegistry::remove))
|
||||
.thenReturn(counter));
|
||||
}
|
||||
|
||||
private Counter emptyCounter(String name) {
|
||||
Counter counter = new Counter();
|
||||
counter.setMetadata(new Metadata());
|
||||
counter.getMetadata().setName(name);
|
||||
return counter;
|
||||
.flatMap(client::delete);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
package run.halo.app.metrics;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.event.post.ReplyEvent;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.DefaultController;
|
||||
import run.halo.app.extension.controller.DefaultDelayQueue;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.extension.controller.RequestQueue;
|
||||
|
||||
/**
|
||||
* Update the comment status after receiving the reply event.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ReplyEventReconciler implements Reconciler<ReplyEvent>, SmartLifecycle {
|
||||
private volatile boolean running = false;
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final RequestQueue<ReplyEvent> replyEventQueue;
|
||||
private final Controller replyEventController;
|
||||
|
||||
public ReplyEventReconciler(ExtensionClient client) {
|
||||
this.client = client;
|
||||
replyEventQueue = new DefaultDelayQueue<>(Instant::now);
|
||||
replyEventController = this.setupWith(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(ReplyEvent request) {
|
||||
Reply requestReply = request.getReply();
|
||||
String commentName = requestReply.getSpec().getCommentName();
|
||||
|
||||
client.fetch(Comment.class, commentName)
|
||||
// if the comment has been deleted, then do nothing.
|
||||
.filter(comment -> comment.getMetadata().getDeletionTimestamp() == null)
|
||||
.ifPresent(comment -> {
|
||||
|
||||
List<Reply> replies = client.list(Reply.class,
|
||||
record -> commentName.equals(record.getSpec().getCommentName())
|
||||
&& record.getMetadata().getDeletionTimestamp() == null,
|
||||
defaultReplyComparator());
|
||||
|
||||
Comment.CommentStatus status = comment.getStatusOrDefault();
|
||||
// total reply count
|
||||
status.setReplyCount(replies.size());
|
||||
|
||||
// calculate last reply time
|
||||
if (!replies.isEmpty()) {
|
||||
Instant lastReplyTime = replies.get(0).getMetadata().getCreationTimestamp();
|
||||
status.setLastReplyTime(lastReplyTime);
|
||||
}
|
||||
|
||||
Instant lastReadTime = comment.getSpec().getLastReadTime();
|
||||
status.setUnreadReplyCount(Comment.getUnreadReplyCount(replies, lastReadTime));
|
||||
|
||||
client.update(comment);
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
Comparator<Reply> defaultReplyComparator() {
|
||||
Function<Reply, Instant> createTime = reply -> reply.getMetadata().getCreationTimestamp();
|
||||
return Comparator.comparing(createTime)
|
||||
.thenComparing(reply -> reply.getMetadata().getName())
|
||||
.reversed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return new DefaultController<>(
|
||||
this.getClass().getName(),
|
||||
this,
|
||||
replyEventQueue,
|
||||
null,
|
||||
Duration.ofMillis(300),
|
||||
Duration.ofMinutes(5));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
this.replyEventController.start();
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
this.running = false;
|
||||
this.replyEventController.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
@EventListener(ReplyEvent.class)
|
||||
public void onReplyAdded(ReplyEvent replyEvent) {
|
||||
replyEventQueue.addImmediately(replyEvent);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
package run.halo.app.metrics;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.event.post.VisitedEvent;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.DefaultController;
|
||||
import run.halo.app.extension.controller.DefaultDelayQueue;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.extension.controller.RequestQueue;
|
||||
|
||||
/**
|
||||
* Update counters after receiving visit event.
|
||||
* It will cache the count in memory for one minute and then batch update to the database.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class VisitedEventReconciler
|
||||
implements Reconciler<VisitedEventReconciler.VisitCountBucket>, SmartLifecycle {
|
||||
private volatile boolean running = false;
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final RequestQueue<VisitCountBucket> visitedEventQueue;
|
||||
private final Map<String, Integer> pooledVisitsMap = new ConcurrentHashMap<>();
|
||||
private final Controller visitedEventController;
|
||||
|
||||
public VisitedEventReconciler(ExtensionClient client) {
|
||||
this.client = client;
|
||||
visitedEventQueue = new DefaultDelayQueue<>(Instant::now);
|
||||
visitedEventController = this.setupWith(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(VisitCountBucket visitCountBucket) {
|
||||
createOrUpdateVisits(visitCountBucket.name(), visitCountBucket.visits());
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
private void createOrUpdateVisits(String name, Integer visits) {
|
||||
client.fetch(Counter.class, name)
|
||||
.ifPresentOrElse(counter -> {
|
||||
Integer existingVisit = ObjectUtils.defaultIfNull(counter.getVisit(), 0);
|
||||
counter.setVisit(existingVisit + visits);
|
||||
client.update(counter);
|
||||
}, () -> {
|
||||
Counter counter = Counter.emptyCounter(name);
|
||||
counter.setVisit(visits);
|
||||
client.create(counter);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the merged data into the queue every minute for updating to the database.
|
||||
*/
|
||||
@Scheduled(cron = "0 0/1 * * * ?")
|
||||
public void queuedVisitBucketTask() {
|
||||
Iterator<Map.Entry<String, Integer>> iterator = pooledVisitsMap.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Integer> item = iterator.next();
|
||||
visitedEventQueue.addImmediately(new VisitCountBucket(item.getKey(), item.getValue()));
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return new DefaultController<>(
|
||||
this.getClass().getName(),
|
||||
this,
|
||||
visitedEventQueue,
|
||||
null,
|
||||
Duration.ofMillis(300),
|
||||
Duration.ofMinutes(5));
|
||||
}
|
||||
|
||||
@EventListener(VisitedEvent.class)
|
||||
public void handlePostPublished(VisitedEvent visitedEvent) {
|
||||
mergeVisits(visitedEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
this.visitedEventController.start();
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
log.debug("Persist visits to database before destroy...");
|
||||
try {
|
||||
Iterator<Map.Entry<String, Integer>> iterator = pooledVisitsMap.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Integer> item = iterator.next();
|
||||
createOrUpdateVisits(item.getKey(), item.getValue());
|
||||
iterator.remove();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to persist visits to database.", e);
|
||||
}
|
||||
this.running = false;
|
||||
this.visitedEventController.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private void mergeVisits(VisitedEvent event) {
|
||||
String counterName =
|
||||
MeterUtils.nameOf(event.getGroup(), event.getPlural(), event.getName());
|
||||
pooledVisitsMap.compute(counterName, (name, visits) -> {
|
||||
if (visits == null) {
|
||||
return 1;
|
||||
} else {
|
||||
return visits + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public record VisitCountBucket(String name, int visits) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
package run.halo.app.metrics;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.event.post.DownvotedEvent;
|
||||
import run.halo.app.event.post.UpvotedEvent;
|
||||
import run.halo.app.event.post.VotedEvent;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.DefaultController;
|
||||
import run.halo.app.extension.controller.DefaultDelayQueue;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.extension.controller.RequestQueue;
|
||||
|
||||
/**
|
||||
* Update counters after receiving upvote or downvote event.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class VotedEventReconciler implements Reconciler<VotedEvent>, SmartLifecycle {
|
||||
private volatile boolean running = false;
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final RequestQueue<VotedEvent> votedEventQueue;
|
||||
private final Controller votedEventController;
|
||||
|
||||
public VotedEventReconciler(ExtensionClient client) {
|
||||
this.client = client;
|
||||
votedEventQueue = new DefaultDelayQueue<>(Instant::now);
|
||||
votedEventController = this.setupWith(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(VotedEvent votedEvent) {
|
||||
String counterName =
|
||||
MeterUtils.nameOf(votedEvent.getGroup(), votedEvent.getPlural(), votedEvent.getName());
|
||||
client.fetch(Counter.class, counterName)
|
||||
.ifPresentOrElse(counter -> {
|
||||
if (votedEvent instanceof UpvotedEvent) {
|
||||
Integer existingVote = ObjectUtils.defaultIfNull(counter.getUpvote(), 0);
|
||||
counter.setUpvote(existingVote + 1);
|
||||
} else if (votedEvent instanceof DownvotedEvent) {
|
||||
Integer existingVote = ObjectUtils.defaultIfNull(counter.getDownvote(), 0);
|
||||
counter.setDownvote(existingVote + 1);
|
||||
}
|
||||
client.update(counter);
|
||||
}, () -> {
|
||||
Counter counter = Counter.emptyCounter(counterName);
|
||||
if (votedEvent instanceof UpvotedEvent) {
|
||||
counter.setUpvote(1);
|
||||
} else if (votedEvent instanceof DownvotedEvent) {
|
||||
counter.setDownvote(1);
|
||||
}
|
||||
client.create(counter);
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return new DefaultController<>(
|
||||
this.getClass().getName(),
|
||||
this,
|
||||
votedEventQueue,
|
||||
null,
|
||||
Duration.ofMillis(300),
|
||||
Duration.ofMinutes(5));
|
||||
}
|
||||
|
||||
@EventListener(VotedEvent.class)
|
||||
public void handlePostPublished(VotedEvent votedEvent) {
|
||||
votedEventQueue.addImmediately(votedEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
this.votedEventController.start();
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
this.running = false;
|
||||
this.votedEventController.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return this.running;
|
||||
}
|
||||
}
|
|
@ -21,7 +21,6 @@ import reactor.core.publisher.Flux;
|
|||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.function.Tuple2;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
@ -293,10 +292,9 @@ public class PostFinderImpl implements PostFinder {
|
|||
comparator, pageNullSafe(page), sizeNullSafe(size))
|
||||
.flatMap(list -> Flux.fromStream(list.get())
|
||||
.concatMap(post -> getListedPostVo(post)
|
||||
.map(postVo -> {
|
||||
populateStats(postVo);
|
||||
return postVo;
|
||||
})
|
||||
.flatMap(postVo -> populateStats(postVo)
|
||||
.doOnNext(postVo::setStats).thenReturn(postVo)
|
||||
)
|
||||
)
|
||||
.collectList()
|
||||
.map(postVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
|
||||
|
@ -306,16 +304,16 @@ public class PostFinderImpl implements PostFinder {
|
|||
.defaultIfEmpty(new ListResult<>(page, size, 0L, List.of()));
|
||||
}
|
||||
|
||||
private <T extends ListedPostVo> void populateStats(T postVo) {
|
||||
Counter counter =
|
||||
counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata()
|
||||
.getName()));
|
||||
StatsVo statsVo = StatsVo.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.comment(counter.getApprovedComment())
|
||||
.build();
|
||||
postVo.setStats(statsVo);
|
||||
private <T extends ListedPostVo> Mono<StatsVo> populateStats(T postVo) {
|
||||
return counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata()
|
||||
.getName()))
|
||||
.map(counter -> StatsVo.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.comment(counter.getApprovedComment())
|
||||
.build()
|
||||
)
|
||||
.defaultIfEmpty(StatsVo.empty());
|
||||
}
|
||||
|
||||
private Mono<ListedPostVo> getListedPostVo(@NonNull Post post) {
|
||||
|
@ -323,8 +321,12 @@ public class PostFinderImpl implements PostFinder {
|
|||
postVo.setCategories(List.of());
|
||||
postVo.setTags(List.of());
|
||||
postVo.setContributors(List.of());
|
||||
populateStats(postVo);
|
||||
|
||||
return Mono.just(postVo)
|
||||
.flatMap(lp -> populateStats(postVo)
|
||||
.doOnNext(lp::setStats)
|
||||
.thenReturn(lp)
|
||||
)
|
||||
.flatMap(p -> {
|
||||
String owner = p.getSpec().getOwner();
|
||||
return contributorFinder.getContributor(owner)
|
||||
|
|
|
@ -11,7 +11,6 @@ import org.springframework.util.CollectionUtils;
|
|||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.SinglePage;
|
||||
import run.halo.app.extension.ListResult;
|
||||
|
@ -93,9 +92,9 @@ public class SinglePageFinderImpl implements SinglePageFinder {
|
|||
.map(singlePage -> {
|
||||
ListedSinglePageVo pageVo = ListedSinglePageVo.from(singlePage);
|
||||
pageVo.setContributors(List.of());
|
||||
populateStats(pageVo);
|
||||
return pageVo;
|
||||
})
|
||||
.flatMap(lp -> populateStats(lp).doOnNext(lp::setStats).thenReturn(lp))
|
||||
.concatMap(this::populateContributors)
|
||||
.collectList()
|
||||
.map(pageVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
|
||||
|
@ -105,16 +104,16 @@ public class SinglePageFinderImpl implements SinglePageFinder {
|
|||
.defaultIfEmpty(new ListResult<>(0, 0, 0, List.of()));
|
||||
}
|
||||
|
||||
<T extends ListedSinglePageVo> void populateStats(T pageVo) {
|
||||
<T extends ListedSinglePageVo> Mono<StatsVo> populateStats(T pageVo) {
|
||||
String name = pageVo.getMetadata().getName();
|
||||
Counter counter =
|
||||
counterService.getByName(MeterUtils.nameOf(SinglePage.class, name));
|
||||
StatsVo statsVo = StatsVo.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.comment(counter.getApprovedComment())
|
||||
.build();
|
||||
pageVo.setStats(statsVo);
|
||||
return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name))
|
||||
.map(counter -> StatsVo.builder()
|
||||
.visit(counter.getVisit())
|
||||
.upvote(counter.getUpvote())
|
||||
.comment(counter.getApprovedComment())
|
||||
.build()
|
||||
)
|
||||
.defaultIfEmpty(StatsVo.empty());
|
||||
}
|
||||
|
||||
<T extends ListedSinglePageVo> Mono<T> populateContributors(T pageVo) {
|
||||
|
|
|
@ -18,4 +18,12 @@ public class StatsVo {
|
|||
Integer upvote;
|
||||
|
||||
Integer comment;
|
||||
|
||||
public static StatsVo empty() {
|
||||
return StatsVo.builder()
|
||||
.visit(0)
|
||||
.upvote(0)
|
||||
.comment(0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -265,9 +265,7 @@ class CommentServiceImplTest {
|
|||
"name": "fake-post"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"hasNewReply": false
|
||||
},
|
||||
"status": {},
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Comment",
|
||||
"metadata": {
|
||||
|
@ -309,9 +307,7 @@ class CommentServiceImplTest {
|
|||
"name": "fake-post"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"hasNewReply": false
|
||||
},
|
||||
"status": {},
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Comment",
|
||||
"metadata": {
|
||||
|
@ -353,9 +349,7 @@ class CommentServiceImplTest {
|
|||
"name": "fake-post"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"hasNewReply": false
|
||||
},
|
||||
"status": {},
|
||||
"apiVersion": "content.halo.run/v1alpha1",
|
||||
"kind": "Comment",
|
||||
"metadata": {
|
||||
|
|
|
@ -33,7 +33,6 @@ import run.halo.app.extension.Metadata;
|
|||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.metrics.CounterMeterHandler;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureWebTestClient
|
||||
|
@ -52,9 +51,6 @@ class UserEndpointTest {
|
|||
@MockBean
|
||||
UserService userService;
|
||||
|
||||
@MockBean
|
||||
CounterMeterHandler counterMeterHandler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// disable authorization
|
||||
|
|
|
@ -6,15 +6,8 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
@ -24,19 +17,14 @@ import org.junit.jupiter.api.Test;
|
|||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.Ref;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.metrics.MeterUtils;
|
||||
|
||||
/**
|
||||
* Tests for {@link CommentReconciler}.
|
||||
|
@ -50,7 +38,6 @@ class CommentReconcilerTest {
|
|||
@Mock
|
||||
private ExtensionClient client;
|
||||
|
||||
private final MeterRegistry meterRegistry = new SimpleMeterRegistry();
|
||||
@Mock
|
||||
SchemeManager schemeManager;
|
||||
|
||||
|
@ -60,42 +47,7 @@ class CommentReconcilerTest {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
commentReconciler = new CommentReconciler(client, meterRegistry, schemeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcile() {
|
||||
Comment comment = new Comment();
|
||||
comment.setMetadata(new Metadata());
|
||||
comment.getMetadata().setName("test");
|
||||
comment.setSpec(new Comment.CommentSpec());
|
||||
comment.getSpec().setLastReadTime(now.plusSeconds(5));
|
||||
comment.setStatus(new Comment.CommentStatus());
|
||||
|
||||
lenient().when(client.fetch(eq(Comment.class), eq("test")))
|
||||
.thenReturn(Optional.of(comment));
|
||||
|
||||
lenient().when(client.list(eq(Reply.class), any(), any()))
|
||||
.thenReturn(replyList());
|
||||
|
||||
Reconciler.Result result = commentReconciler.reconcile(new Reconciler.Request("test"));
|
||||
assertThat(result.reEnqueue()).isTrue();
|
||||
assertThat(result.retryAfter()).isEqualTo(Duration.ofMinutes(1));
|
||||
|
||||
verify(client, times(3)).fetch(eq(Comment.class), eq("test"));
|
||||
verify(client, times(1)).list(eq(Reply.class), any(), any());
|
||||
|
||||
ArgumentCaptor<Comment> captor = ArgumentCaptor.forClass(Comment.class);
|
||||
verify(client, times(2)).update(captor.capture());
|
||||
List<Comment> allValues = captor.getAllValues();
|
||||
Comment value = allValues.get(1);
|
||||
|
||||
assertThat(value.getStatus().getReplyCount()).isEqualTo(3);
|
||||
assertThat(value.getStatus().getLastReplyTime()).isEqualTo(now.plusSeconds(6));
|
||||
assertThat(value.getStatus().getUnreadReplyCount()).isEqualTo(1);
|
||||
assertThat(value.getStatus().getHasNewReply()).isTrue();
|
||||
|
||||
assertThat(value.getMetadata().getFinalizers()).contains(CommentReconciler.FINALIZER_NAME);
|
||||
commentReconciler = new CommentReconciler(client, schemeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -133,85 +85,6 @@ class CommentReconcilerTest {
|
|||
.contains(CommentReconciler.FINALIZER_NAME)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcileCommentCount() {
|
||||
when(client.list(eq(Comment.class), any(), any()))
|
||||
.thenReturn(commentList());
|
||||
when(client.fetch(eq(Comment.class), eq("test")))
|
||||
.thenReturn(Optional.of(getComment("test")));
|
||||
lenient().when(client.list(eq(Reply.class), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
Ref ref = getRef();
|
||||
GroupVersionKind groupVersionKind =
|
||||
new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
|
||||
Scheme scheme = new Scheme(Post.class, groupVersionKind, "posts", "post",
|
||||
Mockito.mock(ObjectNode.class));
|
||||
when(schemeManager.fetch(any())).thenReturn(Optional.of(scheme));
|
||||
|
||||
String fakePostCounterName =
|
||||
MeterUtils.nameOf(ref.getGroup(), scheme.plural(), "fake-post");
|
||||
String testPostCounterName =
|
||||
MeterUtils.nameOf(ref.getGroup(), scheme.plural(), "test-post");
|
||||
|
||||
Counter approvedCommentCounter =
|
||||
MeterUtils.approvedCommentCounter(meterRegistry, fakePostCounterName);
|
||||
approvedCommentCounter.increment(5);
|
||||
|
||||
assertThat(approvedCommentCounter.count()).isEqualTo(5.0);
|
||||
|
||||
Counter testPostCounter =
|
||||
MeterUtils.approvedCommentCounter(meterRegistry, testPostCounterName);
|
||||
testPostCounter.increment(0);
|
||||
assertThat(testPostCounter.count()).isEqualTo(0.0);
|
||||
|
||||
Counter totalCommentCounter =
|
||||
MeterUtils.totalCommentCounter(meterRegistry, fakePostCounterName);
|
||||
totalCommentCounter.increment(8);
|
||||
|
||||
commentReconciler.reconcile(new Reconciler.Request("test"));
|
||||
assertThat(approvedCommentCounter.count()).isEqualTo(3.0);
|
||||
assertThat(testPostCounter.count()).isEqualTo(1.0);
|
||||
assertThat(totalCommentCounter.count()).isEqualTo(5.0);
|
||||
}
|
||||
|
||||
List<Comment> commentList() {
|
||||
final List<Comment> comments = new ArrayList<>();
|
||||
final Comment commentA = getComment("A");
|
||||
final Comment commentB = getComment("B");
|
||||
final Comment commentC = getComment("C");
|
||||
|
||||
Comment commentD = getComment("D");
|
||||
commentD.getSpec().getSubjectRef().setName("test-post");
|
||||
|
||||
final Comment commentE = getComment("E");
|
||||
commentE.getSpec().setApproved(false);
|
||||
|
||||
final Comment commentF = getComment("F");
|
||||
commentF.getSpec().setApproved(false);
|
||||
|
||||
comments.add(commentA);
|
||||
comments.add(commentB);
|
||||
comments.add(commentC);
|
||||
comments.add(commentD);
|
||||
comments.add(commentE);
|
||||
comments.add(commentF);
|
||||
return comments;
|
||||
}
|
||||
|
||||
private Comment getComment(String name) {
|
||||
final Ref ref = getRef();
|
||||
Comment comment = new Comment();
|
||||
comment.setMetadata(new Metadata());
|
||||
comment.getMetadata().setName(name);
|
||||
comment.setSpec(new Comment.CommentSpec());
|
||||
comment.getSpec().setSubjectRef(ref);
|
||||
comment.getSpec().setApproved(true);
|
||||
comment.getSpec().setLastReadTime(now.plusSeconds(5));
|
||||
comment.setStatus(new Comment.CommentStatus());
|
||||
return comment;
|
||||
}
|
||||
|
||||
private static Ref getRef() {
|
||||
Ref ref = new Ref();
|
||||
ref.setGroup("content.halo.run");
|
||||
|
|
|
@ -1,108 +0,0 @@
|
|||
package run.halo.app.metrics;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
/**
|
||||
* Tests for {@link CounterMeterHandler}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CounterMeterHandlerTest {
|
||||
|
||||
@Mock
|
||||
private ReactiveExtensionClient client;
|
||||
|
||||
private final String counterName = MeterUtils.nameOf(Post.class, "fake-post");
|
||||
|
||||
@InjectMocks
|
||||
private CounterMeterHandler counterMeterHandler;
|
||||
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
meterRegistry = new SimpleMeterRegistry();
|
||||
counterMeterHandler = new CounterMeterHandler(client, meterRegistry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onApplicationReady() {
|
||||
Counter counter = new Counter();
|
||||
counter.setMetadata(new Metadata());
|
||||
counter.getMetadata().setName(counterName);
|
||||
counter.setVisit(1);
|
||||
counter.setUpvote(3);
|
||||
counter.setTotalComment(5);
|
||||
counter.setApprovedComment(2);
|
||||
|
||||
Mockito.when(client.list(eq(Counter.class), any(), any()))
|
||||
.thenReturn(Flux.just(counter));
|
||||
|
||||
counterMeterHandler.onApplicationReady(Mockito.mock(ApplicationReadyEvent.class))
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
|
||||
assertThat((int) meterRegistry.find(counterName)
|
||||
.tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.VISIT_SCENE))
|
||||
.counter().count()).isEqualTo(counter.getVisit());
|
||||
|
||||
assertThat((int) meterRegistry.find(counterName)
|
||||
.tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.UPVOTE_SCENE))
|
||||
.counter().count()).isEqualTo(counter.getUpvote());
|
||||
assertThat((int) meterRegistry.find(counterName)
|
||||
.tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.TOTAL_COMMENT_SCENE))
|
||||
.counter().count()).isEqualTo(counter.getTotalComment());
|
||||
assertThat((int) meterRegistry.find(counterName)
|
||||
.tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.APPROVED_COMMENT_SCENE))
|
||||
.counter().count()).isEqualTo(counter.getApprovedComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void save() {
|
||||
MeterUtils.visitCounter(meterRegistry, counterName).increment(2);
|
||||
MeterUtils.upvoteCounter(meterRegistry, counterName).increment(3);
|
||||
|
||||
Mockito.when(client.create(any()))
|
||||
.thenReturn(Mono.just(CounterMeterHandler.emptyCounter(counterName)));
|
||||
Mockito.when(client.fetch(eq(Counter.class), eq(counterName)))
|
||||
.thenReturn(Mono.empty());
|
||||
Mockito.when(client.update(any(Counter.class)))
|
||||
.thenAnswer(a -> {
|
||||
ArgumentCaptor<Counter> captor =
|
||||
ArgumentCaptor.forClass(Counter.class);
|
||||
Mockito.verify(client, Mockito.times(1)).update(captor.capture());
|
||||
Counter value = captor.getValue();
|
||||
assertThat(value.getVisit()).isEqualTo(2);
|
||||
assertThat(value.getUpvote()).isEqualTo(3);
|
||||
assertThat(value.getTotalComment()).isEqualTo(0);
|
||||
assertThat(value.getApprovedComment()).isEqualTo(0);
|
||||
return Mono.just(value);
|
||||
});
|
||||
counterMeterHandler.save()
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
package run.halo.app.metrics;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
/**
|
||||
* Tests for {@link CounterServiceImpl}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CounterServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private ReactiveExtensionClient client;
|
||||
private CounterServiceImpl counterService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SimpleMeterRegistry simpleMeterRegistry = new SimpleMeterRegistry();
|
||||
counterService = new CounterServiceImpl(simpleMeterRegistry, client);
|
||||
String counterName = MeterUtils.nameOf(Post.class, "fake-post");
|
||||
MeterUtils.visitCounter(simpleMeterRegistry,
|
||||
counterName).increment();
|
||||
|
||||
MeterUtils.approvedCommentCounter(simpleMeterRegistry, counterName)
|
||||
.increment(2);
|
||||
|
||||
Counter counter = new Counter();
|
||||
counter.setMetadata(new Metadata());
|
||||
counter.getMetadata().setName(counterName);
|
||||
counter.setVisit(2);
|
||||
|
||||
lenient().when(client.delete(any())).thenReturn(Mono.just(counter));
|
||||
lenient().when(client.fetch(eq(Counter.class), eq(counterName)))
|
||||
.thenReturn(Mono.just(counter));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByName() {
|
||||
run.halo.app.core.extension.Counter counter =
|
||||
counterService.getByName(MeterUtils.nameOf(Post.class, "fake-post"));
|
||||
assertThat(counter.getVisit()).isEqualTo(1);
|
||||
assertThat(counter.getUpvote()).isEqualTo(0);
|
||||
assertThat(counter.getTotalComment()).isEqualTo(0);
|
||||
assertThat(counter.getApprovedComment()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteByName() {
|
||||
String counterName = MeterUtils.nameOf(Post.class, "fake-post");
|
||||
counterService.deleteByName(counterName)
|
||||
.as(StepVerifier::create)
|
||||
.consumeNextWith(counter -> {
|
||||
verify(client, times(1)).delete(any(Counter.class));
|
||||
assertThat(counterService.getByName(counterName).getVisit()).isEqualTo(0);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
|
@ -22,7 +22,6 @@ import reactor.core.publisher.Flux;
|
|||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentService;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.Metadata;
|
||||
|
@ -103,9 +102,7 @@ class PostFinderImplTest {
|
|||
|
||||
@Test
|
||||
void archives() {
|
||||
Counter counter = new Counter();
|
||||
counter.setMetadata(new Metadata());
|
||||
when(counterService.getByName(any())).thenReturn(counter);
|
||||
when(counterService.getByName(any())).thenReturn(Mono.empty());
|
||||
ListResult<Post> listResult = new ListResult<>(1, 10, 3, postsForArchives());
|
||||
when(client.list(eq(Post.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.just(listResult));
|
||||
|
|
Loading…
Reference in New Issue