文章目录

  • 基本介绍
  • 发表评论
  • 查看评论
  • 评论的通知
  • 点赞

基本介绍

博客系统中,用户浏览文章时可以在文章下方发表自己的观点,与博主或其他用户进行互动,也可以为喜欢的文章点赞。下面我们一起分析一下 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 contentpostCommentParam.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;
}
  1. 查询当前 IP 是否处于封禁黑名单(comment_black_list 表)中。

  2. 查询系统设置的时间阈值 banTime(默认是 10 分钟),并判断从 banTime 分钟前到现在,当前 IP 的评论数是否超过了评论数阈值 range(默认是 30 个),如果超过了,那么就需要对当前 IP 实施封禁措施。换句话说,如果 banTime 分钟内,当前 IP 的评论数达到指定阈值,就对当前 IP 进行限流,这里 banTime 是评估封禁的参数,也可以称为时间阈值。

  3. 达到限流条件后,如果当前 IP 存在于封禁黑名单,那么更新 comment_black_list 表,将其解禁时间设置为 banTime 分钟后,虽然 comment_black_list 表中的属性 ban_time 在项目中被称为封禁时间,但结合代码可以发现它的真实含义是解禁时间。如果当前 IP 不在封禁黑名单,那么创建一条新的记录,IP 为当前请求的 IP,解禁时间为 banTime 分钟后。实际上,封禁黑名单的业务含义设置的并不严谨,它的作用仅仅是在数据表中创建或更新一条记录,且记录的解禁时间也只是一个参考值,因为评估 “限流” 的依据是 banTime 分钟前到现在的总评论数,与黑名单中的时间并无关联。Halo 中的 “限流” 机制类似于一个优先队列,队列的容量为 range,元素的属性包括 IP 和入队时间,如果元素入队的时间与当前时间的间隔达到 banTime,那么该元素出队,如果队列已满,那么实施 “限流”,一旦队列恢复出至少一个空闲位置,那么用户便可再次发表评论。

  4. 达到限流条件后返回封禁状态 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 fieldAuthentication authentication = SecurityContextHolder.getContext().getAuthentication();// 博主的评论if (authentication != null) {// Blogger commentUser 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 manuallyValidationUtils.validate(commentParam);// 普通用户的评论if (authentication == null) {// Anonymous comment// Check emailif (userService.getByEmail(commentParam.getEmail()).isPresent()) {throw new BadRequestException("不能使用博主的邮箱,如果您是博主,请登录管理端进行回复。");}}// Convert to commentreturn create(commentParam.convertTo());
}
  1. 从 ThreadLocal 容器中获取用户信息,如果用户信息不为空,那么当前发表评论的用户为博主,因为普通用户是不需要登录的,确认博主身份后在 commentParam 参数中封装博主的信息。

  2. 校验 commentParam 参数是否符合 BaseCommentParam 类中制定的规则,例如评论者的昵称不能为空,邮箱格式必须正确等。

  3. 如果步骤 1 中用户信息为空,那么当前评论来自于普通用户。许多博客系统对普通用户的信息并没有太严格的要求,比如 Halo 中用户发表评论时只需要填写昵称和邮箱,但需要注意普通用户的邮箱不能和管理员的邮箱重复。

上述步骤中的验证操作通过后,执行 create 方法创建评论:

public COMMENT create(@NonNull COMMENT comment) {Assert.notNull(comment, "Domain must not be null");// 确保文章是存在的// Check post idif (!ServiceUtils.isEmptyId(comment.getPostId())) {validateTarget(comment.getPostId());}// 如果 parentId 是非 0 的整数, 那么该评论为用户的回复, 该评论的 "父评论" 必须存在// Check parent idif (!ServiceUtils.isEmptyId(comment.getParentId())) {mustExistById(comment.getParentId());}// Check user login status and set this fieldfinal Authentication authentication =SecurityContextHolder.getContext().getAuthentication();// 设置默认值// Set some default valuesif (comment.getIpAddress() == null) {comment.setIpAddress(ServletUtils.getRequestIp());}// 设置 useragentif (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()));}// 来自于博主的评论, 评论状态直接为 PUBLISHEDif (authentication != null) {// Comment of bloggercomment.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 commentCOMMENT createdComment = super.create(comment);// 如果 parentId 为 0, 表示该评论是对文章的评论if (ServiceUtils.isEmptyId(createdComment.getParentId())) {if (authentication == null) {// 新增评论事件// New comment of guesteventPublisher.publishEvent(new CommentNewEvent(this, createdComment.getId()));}} else {// 回复评论事件// Reply commenteventPublisher.publishEvent(new CommentReplyEvent(this, createdComment.getId()));}return createdComment;
}
  1. 首先确保评论的合理性,即评论所属的文章必须存在,如果评论的 parentId 是非 0 的整数,那么该评论为用户的回复,该评论的 “父评论” 必须存在。

  2. 为评论的 ipAddress、useragent、头像设置默认值(如果为空),并对用户设置的 URL 做规范化处理。

  3. 如果评论来自于博主,那么将 isAdmin 设置为 true,并将评论的状态直接设置为 PUBLISHED。如果评论来自于普通用户且系统开启了审核机制,那么将评论的状态设置为待审核状态 AUDITING,处于 AUDITING 状态的评论需要博主审核通过后才能显示。

  4. 在 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 commentsPage<COMMENT> topCommentPage = baseCommentRepository.findAllByPostIdAndStatusAndParentId(targetId, status, 0L, pageable);if (topCommentPage.isEmpty()) {// If the comments is emptyreturn ServiceUtils.buildEmptyPageImpl(topCommentPage);}// 获取 "直系" 评论的 id 集合// Get top comment idsSet<Long> topCommentIds =ServiceUtils.fetchProperty(topCommentPage.getContent(), BaseComment::getId);// 获取每一条 "直系" 评论的子评论数// Get direct children countList<CommentChildrenCountProjection> directChildrenCount =baseCommentRepository.findDirectChildrenCount(topCommentIds, CommentStatus.PUBLISHED);// map 的 key 是 "直系" 评论的 id, value 是对应的子评论数// Convert to comment - children count mapMap<Long, Long> commentChildrenCountMap = ServiceUtils.convertToMap(directChildrenCount, CommentChildrenCountProjection::getCommentId,CommentChildrenCountProjection::getDirectChildrenCount);// Convert to comment with has children voreturn topCommentPage.map(topComment -> {CommentWithHasChildrenVO comment =new CommentWithHasChildrenVO().convertFrom(topComment);comment.setHasChildren(commentChildrenCountMap.getOrDefault(topComment.getId(), 0L) > 0);comment.setAvatar(buildAvatarUrl(topComment.getGravatarMd5()));return comment;});
}
  1. 首先根据 postId、status、parentId 查询出所有 “直系” 评论,如果为空,那么直接返回空的 Page,否则执行下面的步骤。

  2. 将所有 “直系” 评论的 id 封装在 Set 集合中,并获取每一条评论的子评论数,之后构造 Map,其中 key 为 “直系” 评论的 id,value 为对应的子评论数。

  3. 利用 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 commentsList<PostComment> postComments = postCommentService.listChildrenBy(postId, commentParentId, CommentStatus.PUBLISHED, sort);// Convert to base comment dtoreturn 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 childrenList<COMMENT> directChildren = baseCommentRepository.findAllByPostIdAndStatusAndParentId(targetId, status, commentParentId);// Create result containerSet<COMMENT> children = new HashSet<>();// 递归获取 "直系" 评论的回复的回复// Get children commentsgetChildrenRecursively(directChildren, status, children);// Sort childrenList<COMMENT> childrenList = new ArrayList<>(children);// 对结果进行排序, 按照 commentId 升序排childrenList.sort(Comparator.comparing(BaseComment::getId));return childrenList;
}
  1. 首先获取 “直系” 评论的回复,为了便于表述,我们将 “直系” 评论称为一级评论,那么 “直系” 评论的回复就称为二级评论,以此类推。

  2. 由于二级评论下方也可能会有回复,即三级评论,因此需要递归获取所有的子评论。

  3. 获取到子评论后将所有的评论按照 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 setSet<Long> commentIds = ServiceUtils.fetchProperty(topComments, COMMENT::getId);// 获取下一级评论// Get direct childrenList<COMMENT> directChildren =baseCommentRepository.findAllByStatusAndParentIdIn(status, commentIds);// 获取下下一级评论// Recursively invokegetChildrenRecursively(directChildren, status, children);// 将评论封装在 Set 集合中// Add direct children to children resultchildren.addAll(topComments);
}
  1. 首先获取当前级别评论的 id 集合,并根据 id 集合从数据库中获取下一级评论。

  2. 执行递归方法,根据下一级评论获取下下级评论。

  3. 将当前级别的评论存入到 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 ^)♪。


结语

本文以文章为例,介绍了评论和点赞功能的实现过程,由于对页面的评论和点赞与之类似,因此便不再赘述了。

Halo 开源项目学习(五):评论与点赞相关推荐

  1. Halo 开源项目学习(一):项目启动

    Halo 是一个优秀的开源博客发布应用,在 GitHub 上广受好评,正好最近在练习写博客,借此记录一下学习 Halo 的过程. 项目下载 从 GitHub 上拉取项目源码,Halo 从 1.4.3 ...

  2. Halo 开源项目学习(四):发布文章与页面

    文章目录 基本介绍 管理员发布文章 用户端访问文章 元数据 自定义页面 基本介绍 博客最基本的功能就是让作者能够自由发布自己的文章,分享自己观点,记录学习的过程.Halo 为用户提供了发布文章和展示自 ...

  3. 【2020阿里云部署实战】下载Halo开源项目Jar包并发布

    文章目录 下载jar包并测试 在 Linux 服务器部署 Halo Xftp 连接传输jar包 Xshell 连接发布项目 进阶配置 本栏目,使用halo开源项目快速搭建个人博客,通过阿里云实例的一系 ...

  4. 开源项目学习系列--02.天天酷音TTKMusicplayer

    开源项目学习系列--02.天天酷音TTKMusicplayer 1 简介 2 代码地址 3 基础知识 4 思路 5 代码分析 5.1 编译 5.2 参考 1 简介 天天酷音播放器,核心功能是采用基于 ...

  5. 关于IT8951 Tinydrm Driver开源项目学习的心得

    关于IT8951 Tinydrm Driver开源项目学习的心得 项目名称: julbouln/tinydrm_it8951 项目链接:https://github.com/julbouln/tiny ...

  6. 借助开源项目学习软件开发_借助开源硬件,SparkFun继续创新

    借助开源项目学习软件开发 当SparkFun Electronics创始人兼首席执行官Nathan Seidle在科罗拉多大学攻读工程专业时,他被教导:"真正的工程师想出了一个主意,并为该主 ...

  7. halo 开源项目源码学习

    目的 看开源项目的目的无非就两个,看别人的代码组织结构.看别人的用到得到技术,还有就是看别人踩过的坑. 感受 就我看halo项目的感觉而言.感觉就是注释几乎就没有用.我看似乎这个项目国人挺多的怎么一句 ...

  8. GitHub 优秀的开源项目学习

    转载:原博客地址http://blog.csdn.net/shulianghan/article/details/18046021 一.ListView android-pulltorefresh 一 ...

  9. 开源项目哪家强?Github年终各大排行榜超级盘点(内附开源项目学习资源)

    整理 | Jane 出品 | AI科技大本营 [导语]提到开源项目,2018 年注定是不平凡的一年.据 Octoverse 报告数据,仅在 2018 年,Github 上的新用户就比过去六年的用户总数 ...

最新文章

  1. 有关启动图片Launch的设置
  2. IE webDriver 驱动下载地址
  3. ES6中的const命令【转】
  4. 使用SQL的全文搜索功能构建 Web 搜索应用程序
  5. java小球与小球碰撞_Java实现小球间的弹性碰撞(考虑小球质量)
  6. 零至二岁宝宝故事(一)
  7. UE4_UE5制作3DUI-跟随相机朝向(附工程)
  8. div设置为可获取焦点并且判断是否已获取焦点的方法
  9. 兵法三十六计第二计-围魏救赵。
  10. html盒子移动动画代码,js实现盒子移动动画效果
  11. Linux使用Geany开发gtk程序教程
  12. Jupyter Notebook 菜单栏选项全解说
  13. 第一个python自动化脚本
  14. 如何在JMP中进行相关分析(Correlation Analysis) ?
  15. sqlserver 之STUFF的运用
  16. c++11 入门基础
  17. 怎么将CAD图纸转换为PDF格式?可以将PDF图纸在此转换为CAD格式图纸吗?
  18. centos6.5升级ntp版本至ntp-4.2.6版本以上
  19. 工资条群发神器,HR必备!!!
  20. 2021年美赛F题总结

热门文章

  1. pptv网络电视2014 v3.5.2.0017 免费版
  2. HP打印机常见故障维修资料
  3. shopex个人理解(1)
  4. Windows上如何手动安装Perl模块(ActivePerl)
  5. uni-app 跳转打开QQ对话框
  6. 解决 使用Xftp传输文件状态错误
  7. [offer已拿]字节跳动游戏研发面经
  8. 大公司为什么会倒闭?
  9. 在Oracle中使用分页查询
  10. 我所了解的方正平台(web开发平台)