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
guqing 2022-12-13 11:42:44 +08:00 committed by GitHub
parent cfb66b0faa
commit a9a65dd408
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 749 additions and 696 deletions

View File

@ -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();
}
}

View File

@ -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))

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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";
}

View File

@ -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) {

View File

@ -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

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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) {
}
}

View File

@ -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;
}
}

View File

@ -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)

View File

@ -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) {

View File

@ -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();
}
}

View File

@ -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": {

View File

@ -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

View File

@ -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");

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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));