根本介绍
博客零碎中,用户浏览文章时能够在文章下方发表本人的观点,与博主或其余用户进行互动,也能够为喜爱的文章点赞。上面咱们一起剖析一下 Halo 我的项目中评论和点赞性能的实现过程。
发表评论
评论能够是对文章的评论,对页面的评论,也能够是对评论的评论(通常称为回复),因而我的项目中须要对评论的类别进行划分。评论的实体类 BaseComment 中设置了几个重要的属性:type、postId、parentId。其中 type 用于辨别文章和页面,type 为 0 示意对文章的评论,为 1 示意对页面的评论;postId 用于指定评论属于哪一篇文章或页面;parentId 示意以后评论的 "父评论",如果以后评论是对某个 "父评论" 的回复,那么 parentId 为该 "父评论" 的 id,如果评论文章,那么 parentId 为 0。
进入博客首页,点开一篇文章,在下方发表评论:
点击 "评论" 按钮后,触发 api/content/posts/comments
申请:
该申请由 PostController 中的 comment 办法解决:
@PostMapping("comments")@ApiOperation("Comments a post")@CacheLock(autoDelete = false, traceRequest = true)public BaseCommentDTO comment(@RequestBody PostCommentParam postCommentParam) { // 验证以后 IP 是否处于封禁状态 postCommentService.validateCommentBlackListStatus(); // 对评论的内容进行本义 // Escape content postCommentParam.setContent(HtmlUtils .htmlEscape(postCommentParam.getContent(), StandardCharsets.UTF_8.displayName())); // 创立评论 return postCommentService.convertTo(postCommentService.createBy(postCommentParam));}
comment 办法首先会查看以后发送评论的 IP 是否处于封禁状态,如果未处于封禁状态,那么零碎会对评论的内容进行 HTML 本义,本义实现后创立该评论。首先介绍一下 Halo 的 "封禁评论" 机制,封禁的目标是避免歹意 IP 抢占和节约博客零碎的资源。进入 validateCommentBlackListStatus 办法,查看验证 IP 的具体过程:
public void validateCommentBlackListStatus() { // 查看以后 IP 的封禁状态 CommentViolationTypeEnum banStatus = commentBlackListService.commentsBanStatus(ServletUtils.getRequestIp()); // 获取零碎设置的封禁工夫 Integer banTime = optionService .getByPropertyOrDefault(CommentProperties.COMMENT_BAN_TIME, Integer.class, 10); // 如果以后 IP 处于封禁状态, 提醒用户稍后重试 if (banStatus == CommentViolationTypeEnum.FREQUENTLY) { throw new ForbiddenException(String.format("您的评论过于频繁,请%s分钟之后再试。", banTime)); }}
上述代码中,服务器首先查问以后 IP 的封禁状态,如果状态为 FREQUENTLY,那么就认为以后 IP 的评论过于频繁,而后提醒用户稍后重试。该过程是一种 "限流" 机制,其重点在于如何设计 "频繁评论" 的评判规范,直白一点就是如何 "限流"?限流的形式有很多种,如利用缓存或内存队列等。Halo 中应用数据库来实现限流策略,这个设计思路也是十分值得学习的,commentsBanStatus 办法的解决逻辑如下:
public CommentViolationTypeEnum commentsBanStatus(String ipAddress) { /* N=前期可配置 1. 获取评论次数; 2. 判断N分钟内,是否超过规定的次数限度,超过后须要每隔N分钟能力再次评论; 3. 如果在时隔N分钟内,还有屡次评论,可被认定为歹意攻击者; 4. 对歹意攻击者进行N分钟的封禁; */ // 发送评论的 ip 在封禁是否在封禁名单中 Optional<CommentBlackList> blackList = commentBlackListRepository.findByIpAddress(ipAddress); LocalDateTime now = LocalDateTime.now(); Date endTime = new Date(DateTimeUtils.toEpochMilli(now)); // 封禁的工夫距离, 也是评估是否须要封禁的工夫距离, 默认 10 分钟 Integer banTime = optionService .getByPropertyOrDefault(CommentProperties.COMMENT_BAN_TIME, Integer.class, 10); // now - 工夫距离 Date startTime = new Date(DateTimeUtils.toEpochMilli(now.minusMinutes(banTime))); // 评论数阈值, 默认为 30 个 Integer range = optionService .getByPropertyOrDefault(CommentProperties.COMMENT_RANGE, Integer.class, 30); // 指定工夫距离内, 以后 ip 的评论数是否超过评论数阈值 boolean isPresent = postCommentRepository.countByIpAndTime(ipAddress, startTime, endTime) >= range; if (isPresent && blackList.isPresent()) { // 设置以后 IP 的解禁工夫为 banTime 分钟后 update(now, blackList.get(), banTime); return CommentViolationTypeEnum.FREQUENTLY; } else if (isPresent) { // 构建 CommentBlackList 对象, 设置以后 IP 的解禁工夫为 banTime 分钟后 CommentBlackList commentBlackList = CommentBlackList .builder() .banTime(getBanTime(now, banTime)) .ipAddress(ipAddress) .build(); super.create(commentBlackList); return CommentViolationTypeEnum.FREQUENTLY; } return CommentViolationTypeEnum.NORMAL;}
- 查问以后 IP 是否处于封禁黑名单(comment_black_list 表)中。
- 查问零碎设置的工夫阈值 banTime(默认是 10 分钟),并判断从 banTime 分钟前到当初,以后 IP 的评论数是否超过了评论数阈值 range(默认是 30 个),如果超过了,那么就须要对以后 IP 施行封禁措施。换句话说,如果 banTime 分钟内,以后 IP 的评论数达到指定阈值,就对以后 IP 进行限流,这里 banTime 是评估封禁的参数,也能够称为工夫阈值。
- 达到限流条件后,如果以后 IP 存在于封禁黑名单,那么更新 comment_black_list 表,将其解禁工夫设置为 banTime 分钟后,尽管 comment_black_list 表中的属性 ban_time 在我的项目中被称为封禁工夫,但联合代码能够发现它的实在含意是解禁工夫。如果以后 IP 不在封禁黑名单,那么创立一条新的记录,IP 为以后申请的 IP,解禁工夫为 banTime 分钟后。实际上,封禁黑名单的业务含意设置的并不谨严,它的作用仅仅是在数据表中创立或更新一条记录,且记录的解禁工夫也只是一个参考值,因为评估 "限流" 的根据是 banTime 分钟前到当初的总评论数,与黑名单中的工夫并无关联。Halo 中的 "限流" 机制相似于一个优先队列,队列的容量为 range,元素的属性包含 IP 和入队工夫,如果元素入队的工夫与以后工夫的距离达到 banTime,那么该元素出队,如果队列已满,那么施行 "限流",一旦队列复原出至多一个闲暇地位,那么用户便可再次发表评论。
- 达到限流条件后返回封禁状态 FREQUENTLY,否则返回 NORMAL。
理解了封禁机制后,咱们再说一说 HTML 本义,本义指的是对一些非凡的标签,如 <
、>
、&
等进行本义,使零碎认为其属于一般的符号,不具备标签性能。HTML 内容本义能够无效避免 XSS 攻打,HtmlUtils 工具类中的 htmlEscape 办法可实现本义操作。
接下来,咱们来剖析评论的创立过程,即 createBy 办法的解决逻辑:
public COMMENT createBy(@NonNull BaseCommentParam<COMMENT> commentParam) { Assert.notNull(commentParam, "Comment param must not be null"); // Check user login status and set this field Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 博主的评论 if (authentication != null) { // Blogger comment User user = authentication.getDetail().getUser(); commentParam.setAuthor( StringUtils.isBlank(user.getNickname()) ? user.getUsername() : user.getNickname()); commentParam.setEmail(user.getEmail()); commentParam.setAuthorUrl( optionService.getByPropertyOrDefault(BlogProperties.BLOG_URL, String.class, null)); } // Validate the comment param manually ValidationUtils.validate(commentParam); // 普通用户的评论 if (authentication == null) { // Anonymous comment // Check email if (userService.getByEmail(commentParam.getEmail()).isPresent()) { throw new BadRequestException("不能应用博主的邮箱,如果您是博主,请登录治理端进行回复。"); } } // Convert to comment return create(commentParam.convertTo());}
- 从 ThreadLocal 容器中获取用户信息,如果用户信息不为空,那么以后发表评论的用户为博主,因为普通用户是不须要登录的,确认博主身份后在 commentParam 参数中封装博主的信息。
- 校验 commentParam 参数是否合乎 BaseCommentParam 类中制订的规定,例如评论者的昵称不能为空,邮箱格局必须正确等。
- 如果步骤 1 中用户信息为空,那么以后评论来自于普通用户。许多博客系统对普通用户的信息并没有太严格的要求,比方 Halo 中用户发表评论时只须要填写昵称和邮箱,但须要留神普通用户的邮箱不能和管理员的邮箱反复。
上述步骤中的验证操作通过后,执行 create 办法创立评论:
public COMMENT create(@NonNull COMMENT comment) { Assert.notNull(comment, "Domain must not be null"); // 确保文章是存在的 // Check post id if (!ServiceUtils.isEmptyId(comment.getPostId())) { validateTarget(comment.getPostId()); } // 如果 parentId 是非 0 的整数, 那么该评论为用户的回复, 该评论的 "父评论" 必须存在 // Check parent id if (!ServiceUtils.isEmptyId(comment.getParentId())) { mustExistById(comment.getParentId()); } // Check user login status and set this field final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 设置默认值 // Set some default values if (comment.getIpAddress() == null) { comment.setIpAddress(ServletUtils.getRequestIp()); } // 设置 useragent if (comment.getUserAgent() == null) { comment.setUserAgent(ServletUtils.getHeaderIgnoreCase(HttpHeaders.USER_AGENT)); } // 设置头像 if (comment.getGravatarMd5() == null) { comment.setGravatarMd5( DigestUtils.md5Hex(Optional.ofNullable(comment.getEmail()).orElse(""))); } // 将用户设置的 URL 规范化 if (StringUtils.isNotEmpty(comment.getAuthorUrl())) { comment.setAuthorUrl(HaloUtils.normalizeUrl(comment.getAuthorUrl())); } // 来自于博主的评论, 评论状态间接为 PUBLISHED if (authentication != null) { // Comment of blogger comment.setIsAdmin(true); comment.setStatus(CommentStatus.PUBLISHED); } else { // Comment of guest // Handle comment status // 如果设置了评论审核, 须要将评论状态先设置为待审核状态 Boolean needAudit = optionService .getByPropertyOrDefault(CommentProperties.NEW_NEED_CHECK, Boolean.class, true); comment.setStatus(needAudit ? CommentStatus.AUDITING : CommentStatus.PUBLISHED); } // 创立评论 // Create comment COMMENT createdComment = super.create(comment); // 如果 parentId 为 0, 示意该评论是对文章的评论 if (ServiceUtils.isEmptyId(createdComment.getParentId())) { if (authentication == null) { // 新增评论事件 // New comment of guest eventPublisher.publishEvent(new CommentNewEvent(this, createdComment.getId())); } } else { // 回复评论事件 // Reply comment eventPublisher.publishEvent(new CommentReplyEvent(this, createdComment.getId())); } return createdComment;}
- 首先确保评论的合理性,即评论所属的文章必须存在,如果评论的 parentId 是非 0 的整数,那么该评论为用户的回复,该评论的 "父评论" 必须存在。
- 为评论的 ipAddress、useragent、头像设置默认值(如果为空),并对用户设置的 URL 做规范化解决。
- 如果评论来自于博主,那么将 isAdmin 设置为 true,并将评论的状态间接设置为 PUBLISHED。如果评论来自于普通用户且零碎开启了审核机制,那么将评论的状态设置为待审核状态 AUDITING。
- 在 comments 表中创立评论,并公布相应的事件。如果评论的 parentId 为 0(该评论是对文章的评论)且该评论来自于普通用户,那么公布 "新增评论" 事件。如果 parentId 不为 0,公布 "回复评论" 事件。
create 办法执行胜利后,一条评论就创立实现了 (^~^)!
查看评论
咱们在浏览文章时,能够看到文章底下用户的评论,其中排在后面的通常是一些高赞评论(神回复)或最新评论。如果评论数量较多,那么局部评论可能会被折叠,例如关上一篇文章:
上图中,咱们只能看到文章的 "直系" 评论(对该文章的评论),而看不到对评论的评论。当文章被点开时,前端不仅会发送 archives/{slug}
申请来获取文章的具体内容,还会发送 api/content/posts/{postId}/comments/top_view
申请来获取属于该文章的 "直系" 评论,该申请由 PostController 中的 listTopComments 办法解决:
@GetMapping("{postId:\\d+}/comments/top_view")public Page<CommentWithHasChildrenVO> listTopComments(@PathVariable("postId") Integer postId, @RequestParam(name = "page", required = false, defaultValue = "0") int page, @SortDefault(sort = "createTime", direction = DESC) Sort sort) { return postCommentService.pageTopCommentsBy(postId, CommentStatus.PUBLISHED, PageRequest.of(page, optionService.getCommentPageSize(), sort));}
"Top comments" 指的就是 "直系" 评论,进入 pageTopCommentsBy 办法,查看列举 Top comments 的具体过程:
public Page<CommentWithHasChildrenVO> pageTopCommentsBy(@NonNull Integer targetId, @NonNull CommentStatus status, @NonNull Pageable pageable) { Assert.notNull(targetId, "Target id must not be null"); Assert.notNull(status, "Comment status must not be null"); Assert.notNull(pageable, "Page info must not be null"); // 依据 postId、status、parentId 查问出所有 "直系" 评论, 非回复 // Get all comments Page<COMMENT> topCommentPage = baseCommentRepository .findAllByPostIdAndStatusAndParentId(targetId, status, 0L, pageable); if (topCommentPage.isEmpty()) { // If the comments is empty return ServiceUtils.buildEmptyPageImpl(topCommentPage); } // 获取 "直系" 评论的 id 汇合 // Get top comment ids Set<Long> topCommentIds = ServiceUtils.fetchProperty(topCommentPage.getContent(), BaseComment::getId); // 获取每一条 "直系" 评论的子评论数 // Get direct children count List<CommentChildrenCountProjection> directChildrenCount = baseCommentRepository.findDirectChildrenCount(topCommentIds, CommentStatus.PUBLISHED); // map 的 key 是 "直系" 评论的 id, value 是对应的子评论数 // Convert to comment - children count map Map<Long, Long> commentChildrenCountMap = ServiceUtils .convertToMap(directChildrenCount, CommentChildrenCountProjection::getCommentId, CommentChildrenCountProjection::getDirectChildrenCount); // Convert to comment with has children vo return topCommentPage.map(topComment -> { CommentWithHasChildrenVO comment = new CommentWithHasChildrenVO().convertFrom(topComment); comment .setHasChildren(commentChildrenCountMap.getOrDefault(topComment.getId(), 0L) > 0); comment.setAvatar(buildAvatarUrl(topComment.getGravatarMd5())); return comment; });}
- 首先依据 postId、status、parentId 查问出所有 "直系" 评论,如果为空,那么间接返回空的 Page,否则执行上面的步骤。
- 将所有 "直系" 评论的 id 封装在 Set 汇合中,并获取每一条评论的子评论数,之后结构 Map,其中 key 为 "直系" 评论的 id,value 为对应的子评论数。
- 利用 CommentWithHasChildrenVO 封装每一条评论的内容,并判断评论是否蕴含子评论(如果蕴含子评论,前端页面会显示 "更多" 按钮),最初设置头像信息。
通过上述操作能够查看到用户对文章的评论,如果心愿看到对评论的回复,则须要点击 "更多" 按钮,此时前端发送 api/content/posts/{postId}/comments/{commentParentId}/children
申请,该申请由 PostController 中的 listChildrenBy 办法解决:
@GetMapping("{postId:\\d+}/comments/{commentParentId:\\d+}/children")public List<BaseCommentDTO> listChildrenBy(@PathVariable("postId") Integer postId, @PathVariable("commentParentId") Long commentParentId, @SortDefault(sort = "createTime", direction = DESC) Sort sort) { // Find all children comments List<PostComment> postComments = postCommentService .listChildrenBy(postId, commentParentId, CommentStatus.PUBLISHED, sort); // Convert to base comment dto return postCommentService.convertTo(postComments);}
上述办法中,parentId 为某一条 "直系" 评论的 id,此 id 是一个大于 0 的整数,办法会返回该 "直系" 评论下状态为 PUBLISHED 的所有子评论。上面咱们进入 service 层中的 listChildrenBy 办法,查看具体的逻辑:
public List<COMMENT> listChildrenBy(@NonNull Integer targetId, @NonNull Long commentParentId, @NonNull CommentStatus status, @NonNull Sort sort) { Assert.notNull(targetId, "Target id must not be null"); Assert.notNull(commentParentId, "Comment parent id must not be null"); Assert.notNull(sort, "Sort info must not be null"); // Get comments recursively // 获取 "直系" 评论的回复 // Get direct children List<COMMENT> directChildren = baseCommentRepository .findAllByPostIdAndStatusAndParentId(targetId, status, commentParentId); // Create result container Set<COMMENT> children = new HashSet<>(); // 递归获取 "直系" 评论的回复的回复 // Get children comments getChildrenRecursively(directChildren, status, children); // Sort children List<COMMENT> childrenList = new ArrayList<>(children); // 对后果进行排序, 依照 commentId 升序排 childrenList.sort(Comparator.comparing(BaseComment::getId)); return childrenList;}
- 首先获取 "直系" 评论的回复,为了便于表述,咱们将 "直系" 评论称为一级评论,那么 "直系" 评论的回复就称为二级评论,以此类推。
- 因为二级评论下方也可能会有回复,即三级评论,因而须要递归获取所有的子评论。
- 获取到子评论后将所有的评论依照 id 升序排列并返回。
实际上在许多网站中,属于文章的评论("直系" 评论)和属于评论的评论的确会做一个分级展现,但评论的评论之间个别是并列展现的:
上图中,"直系" 评论下方有两条子评论,其中子评论 2("好的")是对子评论 1("收到")的回复,二者在前端的排版上属于同一级,但为了更好天文清其中的逻辑关系,咱们将其分为 "二级" 和 "三级"。上面查看递归办法 getChildrenRecursively 查找不同级别评论的过程:
private void getChildrenRecursively(@Nullable List<COMMENT> topComments, @NonNull CommentStatus status, @NonNull Set<COMMENT> children) { Assert.notNull(status, "Comment status must not be null"); Assert.notNull(children, "Children comment set must not be null"); if (CollectionUtils.isEmpty(topComments)) { return; } // 以后级别评论的 id 汇合 // Convert comment id set Set<Long> commentIds = ServiceUtils.fetchProperty(topComments, COMMENT::getId); // 获取下一级评论 // Get direct children List<COMMENT> directChildren = baseCommentRepository.findAllByStatusAndParentIdIn(status, commentIds); // 获取下下一级评论 // Recursively invoke getChildrenRecursively(directChildren, status, children); // 将评论封装在 Set 汇合中 // Add direct children to children result children.addAll(topComments);}
- 首先获取以后级别评论的 id 汇合,并依据 id 汇合从数据库中获取下一级评论。
- 执行递归办法,依据下一级评论获取下上级评论。
- 将以后级别的评论存入到 Set 汇合。
以上便是用户浏览评论时后盾服务器的解决流程。
评论的告诉
Halo 中设置了评论告诉的性能,当用户发送评论时,零碎会发送邮件告诉博主。当博主回复用户的评论时,如果用户设置的 Email 是无效的邮箱,那么博主回复的内容也会被发送到用户的邮箱中。上面以 QQ 邮箱为例,介绍一下操作过程。
首先进入 QQ 邮箱,点击左上角的 "设置",抉择 "账户"。向下拉,找到 "POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务",开启 "POP3/SMTP服务":
而后点击生成受权码,应用密保手机发送指定短信后就能够收到受权码。接着进入 Halo 的管理员界面,点击 "零碎" -> "博客设置",抉择 "SMTP 服务" 并填写信息,邮箱账号填写本人的 QQ 邮箱,明码是生成的受权码:
填写实现后点击保留,在右侧的 "发送测试" 选项中测试是否能失常发送邮件,收件人地址须要填写一个正确的 Email 地址,个别状况下是可能发送胜利的。此外,还须要在 "评论设置" 选项中开启 "评论回复告诉对方",最初保留设置。这样,当博主回复时,用户的邮箱就能够收到博主回复的内容了:
点赞
Halo 我的项目中,文章的点赞量是作为一个属性封装在 BasePost 实体中的,因而更新点赞量的时候须要更新 posts 数据表,这种解决形式在用户量较少的集体博客零碎中是可行的,因为并发量个别不会超出数据库的可接受范畴,而且因为普通用户不须要登录,所以能够不必思考用户的勾销点赞操作。然而须要留神的是,对于领有大量用户的博客平台或者社区论坛零碎,此种形式就不再实用了,因为点赞操作是一个高频调用的性能,频繁操作数据库可能会使服务器解体。
上面咱们剖析一下点赞性能的实现过程,因为默认的主题 caicai_anatole
没有提供点赞按钮,所以咱们将主题更换为 joe2.0
(其它主题也可)。进入文章详情页,点击 "点赞" 按钮后,触发 /api/content/posts/1/likes
申请,该申请由 PostController 中的 like 办法解决:
@PostMapping("{postId:\\d+}/likes")@ApiOperation("Likes a post")@CacheLock(autoDelete = false, traceRequest = true)public void like(@PathVariable("postId") @CacheParam Integer postId) { postService.increaseLike(postId);}
like 办法调用 increaseLike 办法来减少点赞量,increaseLike 办法的解决逻辑也非常简单,就是将 id 为 postId 的文章的点赞量加一 (^ o ^)♪。
结语
本文以文章为例,介绍了评论和点赞性能的实现过程,因为对页面的评论和点赞与之相似,因而便不再赘述了。