当前位置: 首页 > news >正文

天机学堂(上)

目录

一.初识项目

(一)天机学堂介绍

1.行业背景

2.行业图谱

3. 系统架构

4.技术架构

5.功能演示

(1)老师核心业务

(2)学员核心业务

(二)项目环境搭建

1.企业开发模式

2.导入虚拟机

3.配置本机hosts

4.部署

(1)虚拟机部署

(2)本地部署

(三)修复BUG

1.熟悉项目

(1)项目结构

(2)实体类规范

(3)异常处理

(4)配置文件

①bootstrap.yml

②shared-spring.yml

③shared-mybatis.yaml

④shared-mq.yaml

⑤shared-redis.yaml

⑥shared-feign.yaml

⑦shared-xxljob.yaml

2.远程调试

3.测试部署

二.我的课表

(一)接口设计

1. 分页查询我的课表

2. 添加课程到课程表

3.查询正在学习的课程

4.根据id查询指定课程的学习状态

(二)数据结构

1.表结构

2.创建分支

3. 代码生成

4.状态枚举

(三)实现接口功能

1. 添加课程到课表

2.分页查询我的课表

(1)获取登录用户

①实现思路

②网关鉴权

③用户信息上下文

(2)实现查询我的课表

3.查询正在学习的课程

(1)查询章节信息

(2)代码实现

4.检查课程是否有效

5. 查询用户课表中指定课程状态

6.统计课程的学习人数

7.删除课表中课程(没讲,自己做的)

三.学习计划和进度

(一)分析产品原型

1. 分析业务流程

2.设计业务接口

(1)提交学习记录

(2)根据id查询指定课程的学习记录

(3)创建学习计划

(4)查询最近正在学习的课程学习计划

3.设计数据库

4.生成基础代码

(二)实现接口

1. 根据id查询指定课程的学习记录

2. 提交学习记录

3. 创建学习计划

4.查询学习计划

四. 高并发优化

(一)高并发优化方案

1.单机并发能力

2. 变同步为异步

3.合并写请求

(二)播放进度记录方案改进

1.优化方案设计

2.Redis数据结构设计

3.持久化思路

(三)延迟任务

1.延迟任务方案

2.DelayQueue的原理

3.DelayQueue的用法

(4)代码改造

1.定义延迟任务工具类

2.改造提交学习记录功能

五.互动问答

(一)分析产品原型

1.分析业务流程

2.设计业务接口

(1)新增互动问题接口

(2)编辑互动问题接口

(3)用户端分页查询问题

(4)用户端根据id查询问题详情

(5)管理端分页查询问题

(6)管理端分页查询问题

(7)管理端根据id查询问题详情

(8)新增回答或评论

(9)分页查询回答和评论列表

3.设计数据库

4.代码生成

(二)问题相关接口

1.新增互动问题

2. 用户端分页查询问题

3. 根据id查询问题详情

4.管理端分页查询问题

5. 修改互动问题(练习)

6.删除我的问题(练习)

7.管理端隐藏或显示问题(练习)

六.点赞系统

(一)需求分析

1.业务需求

2.实现思路

(二)数据结构

1.ER图

2.表结构

3.创建微服务

4.代码生成

(三)实现点赞功能

(四)批量查询点赞状态

(五)监听点赞变更的消息 

(六)点赞功能改进

1.点赞数据缓存

(1)用户是否点赞

(2)点赞次数

2.点赞数据入库

3.流程图

4. 改造点赞逻辑

(1)点赞接口

(2)批量查询点赞状态统计

(3)定时任务

(4)监听点赞数变更


一.初识项目

(一)天机学堂介绍

天机学堂是一个基于微服务架构的生产级在线教育项目,核心用户不是K12群体,而是面向成年人的非学历职业技能培训平台。相比之前的项目课程,其业务完整度、真实度、复杂度都非常的高,与企业真实项目非常接近。

通过天机学堂项目,能学习到在线教育中核心的学习辅助系统、考试系统,电商类项目的促销优惠系统等等。更能学习到微服务开发中的各种热点问题,以及不同场景对应的解决方案。

1.行业背景

2021年7月,国务院颁布《关于进一步减轻义务教育阶段学生作业负担和校外培训负担的意见》,简称“双减”政策。在该政策影响下,多年来占据我国教育培训行业半壁江山的课外辅导培训遭到毁灭性打击。相对的,职业教育培训的市场规模持续增长:

职业教育的市场规模持续增长,增长率保持在12%以上,总规模即将突破万亿,可见职业教育前景大好。职业教育培训分为有学历和非学历两大类:

天机学堂的核心业务就是非学历的职业技能培训

另外,职业教育有线上和线下之分,随着互联网发展,传统行业也逐渐网络化发展。再加上疫情的影响,很多职业技能培训企业都开始发展在线教育。相比于传统线下培训,在线教育有成本更低,学习时间碎片化,教育资源能充分利用。因此,在线教育市场规模不断增长,前景巨大。

2.行业图谱

职业教育产业图谱:

职业教育产业链分为三大部分:

上游:由配套服务商、平台服务商、师资服务商和内容服务商构成。

中游:由学历和非学历的职业教育服务商 构成, 主要提供教育和培训服务。

下游:是职业教育需求方, 其中现阶段学历职业教育主要面向 15-22 岁的 C 端学生, 非学历职业培训的受众则更为广泛,基本覆盖了中考毕业以后所有年龄阶层的学生,此外职业技能培训和企业培训公司还向 B 端企业提供服务

天机学堂正是属于中游的非学历职业技能培训的一家企业。

3. 系统架构

天机学堂目前是一个B2C类型的教育网站,因此分为两个端:

  • 后台管理端

  • 用户端(PC网站)

整体架构如下:

4.技术架构

5.功能演示

天机学堂分为两部分:

  • 学生端:其核心业务主体就是学员,所有业务围绕着学员的展开

  • 管理端:其核心业务主体包括老师、管理员、其他员工,核心业务围绕着老师展开

(1)老师核心业务

例如,老师的核心业务流程有:

虽然流程并不复杂,但其中包含的业务繁多,例如:

  • 课程分类管理:课程分类的增删改查

  • 媒资管理:媒资的增删改查、媒资审核

  • 题目管理:试题的增删改查、试题批阅、审核

  • 课程管理:课程增删改查、课程上下架、课程审核、发布等等

(2)学员核心业务

学员的核心业务就是买课、学习,基本流程如下:

(二)项目环境搭建

为了模拟真实的开发场景,我们设定的场景是这样的:天机学堂项目已经完成1.0.0版本60%的功能开发,能够实现项目的课程管理、课程购买等业务流程。现在需要加入课程学习、优惠促销、评价等功能。

相关微服务及1.0.0版本的完成状态如下:

微服务名称

功能描述

完成状态

tj-parent

父工程

tj-common

通用工程

tj-message

消息中心

tj-gateway

网关

tj-auth

权限服务

tj-user

用户服务

tj-pay

支付服务

tj-course

课程服务

tj-exam

考试服务

O

tj-search

搜索服务

tj-trade

交易服务

O

tj-learning

学习服务

X

tj-promotion

促销服务

X

tj-media

媒资服务

tj-data

数据服务

O

tj-remark

评价服务

X

1.企业开发模式

在企业开发中,微服务项目非常庞大,往往有十几个,甚至数十个,数百个微服务。而这些微服务也会交给不同的开发组去完成开发。你可能只参与其中的某几个微服务开发,那么问题来了:

如果我的微服务需要访问其它微服务怎么办?

难道说我需要把所有的微服务都部署到自己的电脑吗?

很明显,这样做是不现实的。第一,不是所有的代码你都有访问的权限;第二,你的电脑可能无法运行这数十、数百的微服务。

因此,企业往往会提供一个通用的公共开发、测试环境,在其中部署很多公共服务,以及其它团队开发好的、开发中的微服务。

而我们大多数情况下只在本地运行正在开发的微服务,此时我们就需要一些其它的测试手段:

  • 单元测试:测试最小的可测试单元,单元测试一般是在项目的test目录下自己编写的测试,可以针对具体到每一个方法的测试。

  • 集成测试:验证某些功能接口,是否能与其它微服务正确交互。接口开发完成后,可能需要调用其它微服务接口,此时可以调用开发环境中的其它微服务,测试接口功能是否正常工作。

  • 组件测试:验证微服务组件,将自己团队开发的微服务部署到开发环境,作为一个微服务组件,与开发环境中的其它微服务联调,测试整个微服务是否正常工作。

  • 端对端联调:验证整个系统,在测试环境部署前端、后端微服务群,直接进行前后端的联调测试。

当然,实际中我们可以把集成测试与组件测试合并,开发完成后直接与开发环境的其它微服务联调,测试服务工作状态。

在天机学堂中,模拟了这样的一个开发环境,其中部署了各种公共服务,而我们只需要在本地开发未完成的几个服务即可:

2.导入虚拟机

VMware安装过程省略,建议版本使用15.5以上版本。

默认虚拟机设置的内存大小为8G,虚拟内存为8GB,建议保持此配置,不建议进行调整。

配置VMware网络:

因为虚拟机配置了静态IP地址为192.168.150.101,因此需要VMware软件的虚拟网卡采用与虚拟机相同的网段。

配置VMware:

首先,在VMware中选择编辑,虚拟网络编辑器:

这里需要管理员权限,因此要点击更改设置:

接下来,就可以修改虚拟网卡的IP地址了,流程如图:

注意:一定要严格按照标号顺序修改,并且IP地址也要保持一致!

验证:

点击确定后,等待一段时间,VMware会重置你的虚拟网卡。完成后,可以在windows的网络控制面板看到:

选中该网卡,右键点击,在菜单中选择状态,并在弹出的状态窗口中选择详细信息:

在详细信息中,查看IPv4地址是否是 192.168.150.1:

如果一致,则证明配置成功!

导入虚拟机:

打开VMware,选择文件,然后打开:

到提供的虚拟机文件夹,进入文件夹后,选中*.vmx文件,然后点击打开:

导入成功:

启动虚拟机,选择【我已复制该虚拟机】:

登入:

虚拟机登入信息如下:

# 用户名
root
# 密码
123321

通过FinallShell的ssh连接:

测试网络:

最后,通过命令测试网络是否畅通:

ping baidu.com

为了模拟企业中的开发环境,我们利用虚拟机搭建了一套开发环境,其中部署了开发常用的组件:

  • Git私服(gogs):代码全部提交带了自己的Git私服,模拟企业开发的代码管理,大家也需要自行到私服拉取代码

  • jenkins:持续集成,目前已经添加了所有部署脚本和Git钩子,代码推送会自动编译,可以根据需求手动部署

  • nacos:服务注册中心、统一配置管理,大多数共享的配置都已经交给nacos处理

  • seata:分布式事务管理

  • xxl-job:分布式任务系统

  • es:索引库

  • redis:缓存库

  • mysql:数据库

  • kibana:es控制台

如图:

3.配置本机hosts

为了模拟使用域名访问,我们需要在本地配置hosts:

192.168.150.101 git.tianji.com
192.168.150.101 jenkins.tianji.com
192.168.150.101 mq.tianji.com
192.168.150.101 nacos.tianji.com
192.168.150.101 xxljob.tianji.com
192.168.150.101 es.tianji.com
192.168.150.101 api.tianji.com
192.168.150.101 www.tianji.com
192.168.150.101 manage.tianji.com
192.168.150.101 cpolar.tianji.com

使用SwitchHosts进行修改,官网:http://github.com/oldj/SwitchHosts/releases

下载后添加hosts:

当我们访问上述域名时,请求实际是发送到了虚拟机,而虚拟机中的Nginx会对这些域名做反向代理,这样我们就能请求到对应的组件了:

在/usr/local/src/nginx/conf/nginx.conf里已经配置好:

在浏览器中输入对应域名,即可查看到对应服务

每个域名对应的服务列表如下:

名称

域名

账号

端口

Git私服

git.tianji.com

tjxt/123321

10880

Jenkins持续集成

jenkins.tianji.com

root/123

18080

RabbitMQ

mq.tianji.com

tjxt/123321

15672

Nacos控制台

nacos.tianji.com

nacos/nacos

8848

xxl-job控制台

xxljob.tianji.com

admin/123456

8880

ES的Kibana控制台

es.tianji.com

-

5601

微服务网关

api.tianji.com

-

10010

用户端入口

www.tianji.com

-

18081

管理端入口

manage.tianji.com

-

18082

同样,我们访问用户端或者管理端页面时,也会被Nginx反向代理:

当我们访问www.tianji.com时,请求会被代理到虚拟机中的 /usr/local/src/tj-portal目录中的静态资源

当页面访问api.tianji.com时,请求会被代理到虚拟机中的网关服务。

4.部署

微服务部署比较麻烦,所以企业中都会采用持续集成的方式,快捷实现开发、部署一条龙服务。

为了模拟真实环境,我们在虚拟机中已经提供了一套持续集成的开发环境,代码一旦自测完成,push到Git私服后即可自动编译部署。

而开发我们负责的微服务时,则需要在本地启动运行部分微服务。

(1)虚拟机部署

项目已经基于Jenkins实现了持续集成,每当我们push代码时,就会触发项目完成自动编译和打包。

我们可以在Git仓库模拟代码push操作:

  • 首先,访问http://git.tianji.com(tjxt/123321),找到tianji这个仓库,点击仓库设置按钮:

  • 然后,点击《管理Web钩子》菜单,进入页面后点击钩子后面的修改按钮:

  • 进入页面后,向下滚动,点击测试推送按钮:
  • 然后回到jenkins页面,会发现已经触发了tjxt-dev-build的自动编译

需要运行某个微服务时,我们只需要经过两步:

  • 第一步,访问jenkins控制台:http://jenkins.tianji.com (账号:root/123)

  • 第二步,点击对应微服务后面的运行按钮

构建过程中,可以在页面左侧看到构建进度,如果没有说明构建已经结束了

完成后,点击对应的微服务名称【例如tj-gateway】,即可进入构建任务的详情页面,在页面左侧可以看到构建历史:

其中#1代表第一次构建,点击前面的√即可查看构建日志:

看到上面的日志,说明构建已经成功,容器也成功运行了。

我们需要分别启动几个开发完成的微服务:

  • tj-user

  • tj-auth

  • tj-gateway

  • tj-course

  • tj-media

  • tj-search

  • tj-exam

  • tj-data

此时访问Nacos控制台,可以看到微服务都成功注册了:

此时访问 http://www.tianji.com (jack/123 rose/123456)即可看到用户端页面:

此时访问 http://manage.tianji.com 即可看到管理端页面:

如果想要知道微服务具备哪些API接口,可以访问网关中的swagger页面,路径如下:

http://api.tianji.com/doc.html,其中可以查看所有微服务的接口信息

(2)本地部署

对于需要开发功能的微服务,则需要在本地部署,不过首先我们要把代码拉取下来。

查看Git私服的代码:http://git.tianji.com/tjxt/tianji :

将代码克隆到自己的IDEA工作空间中, master分支是完整项目代码,上课要从lesson-init分支开发:

# 在IDEA空间运行,最好不要包含中文和空格,以lesson-init分支为起点
git clone http://192.168.150.101:10880/tjxt/tianji.git -b lesson-init

lesson-init分支为起点,创建一个dev分支,完成项目开发:

# 进入项目目录
cd tianji
# 以lesson-init分支为起点,创建并切换到dev分支
git checkout -b dev

目前所有微服务代码都聚合在了一个Project中,如图:

我们的微服务都支持多环境部署,因此配置文件有多个:

部署到虚拟机中会使用dev环境配置,而在本地应该使用local环境配置。

在默认情况下,微服务启用的是dev配置,如果要在本地运行,需要设置profile为local:

在本地启动ExamApplication,然后我们去Nacos控制台查看exam-service,可以看到有两个实例,分别是虚拟机IP和宿主机IP:

如果只想把该微服务用来拉取其他微服务(订阅)不注册到nocos,则加上register-enabled: false:

spring:cloud:nacos:server-addr: 192.168.150.101:8848 # nacos注册中心discovery:namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0group: DEFAULT_GROUPip: 192.168.150.1register-enabled: false
logging:level:com.tianji: debug

(三)修复BUG

1.熟悉项目

(1)项目结构

项目结构,目前企业微服务开发项目结构有两种模式:

  • 每个项目下的每一个微服务,需要创建一个Project,尽可能降低耦合
  • 每个项目创建一个Project ,项目下的多个微服务是Project下的Module,方便管理

方案一更适合于大型项目,架构更为复杂,管理和维护成本都比较高;

方案二更适合中小型项目,架构更为简单,管理和维护成本都比较低;

天机学堂采用的正是第二种模式,结构如图:

对应到我们项目中每个模块及功能如下:

当我们要创建新的微服务时,也必须以tjxt为父工程,创建一个子module. 例如交易微服务:

微服务module中如果有对外暴露的Feign接口,需要定义到tj-api模块中:

(2)实体类规范

在天机学堂项目中,所有实体类按照所处领域不同,划分为4种不同类型:

  • DTO:数据传输对象,在客户端与服务端间传递数据,例如微服务之间的请求参数和返回值、前端提交的表单

  • PO:持久层对象,与数据库表一一对应,作为查询数据库时的返回值

  • VO:视图对象,返回给前端用于封装页面展示的数据

  • QUERY:查询对象,一般是用于封装复杂查询条件

例如交易服务:

(3)异常处理

在项目运行过程中,或者业务代码流程中,可能会出现各种类型异常,为了加以区分,我们定义了一些自定义异常对应不同场景:

在开发业务的过程中,如果出现对应类型的问题,应该优先使用这些自定义异常。

当微服务抛出这些异常时,需要一个统一的异常处理类,同样在tj-common模块中定义了:

@RestControllerAdvice
@Slf4j
public class CommonExceptionAdvice {@ExceptionHandler(DbException.class)public Object handleDbException(DbException e) {log.error("mysql数据库操作异常 -> ", e);return processResponse(e.getStatus(), e.getCode(), e.getMessage());}@ExceptionHandler(CommonException.class)public Object handleBadRequestException(CommonException e) {log.error("自定义异常 -> {} , 状态码:{}, 异常原因:{}  ",e.getClass().getName(), e.getStatus(), e.getMessage());log.debug("", e);return processResponse(e.getStatus(), e.getCode(), e.getMessage());}@ExceptionHandler(FeignException.class)public Object handleFeignException(FeignException e) {log.error("feign远程调用异常 -> ", e);return processResponse(e.status(), e.status(), e.contentUTF8());}@ExceptionHandler(MethodArgumentNotValidException.class)public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {String msg = e.getBindingResult().getAllErrors().stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining("|"));log.error("请求参数校验异常 -> {}", msg);log.debug("", e);return processResponse(400, 400, msg);}@ExceptionHandler(BindException.class)public Object handleBindException(BindException e) {log.error("请求参数绑定异常 ->BindException, {}", e.getMessage());log.debug("", e);return processResponse(400, 400, "请求参数格式错误");}@ExceptionHandler(NestedServletException.class)public Object handleNestedServletException(NestedServletException e) {log.error("参数异常 -> NestedServletException,{}", e.getMessage());log.debug("", e);return processResponse(400, 400, "请求参数异常");}@ExceptionHandler(ConstraintViolationException.class)public Object handViolationException(ConstraintViolationException e) {log.error("请求参数异常 -> ConstraintViolationException, {}", e.getMessage());return processResponse( HttpStatus.OK.value(), HttpStatus.BAD_REQUEST.value(),e.getConstraintViolations().stream().map(ConstraintViolation::getMessage).distinct().collect(Collectors.joining("|")));}@ExceptionHandler(Exception.class)public Object handleRuntimeException(Exception e) {log.error("其他异常 uri : {} -> ", WebUtils.getRequest().getRequestURI(), e);return processResponse(500, 500, "服务器内部异常");}private Object processResponse(int status, int code, String msg){// 1.标记响应异常已处理(避免重复处理)WebUtils.setResponseHeader(Constant.BODY_PROCESSED_MARK_HEADER, "true");// 2.如果是网关请求,http状态码修改为200返回,前端基于业务状态码code来判断状态// 如果是微服务请求,http状态码基于异常原样返回,微服务自己做fallback处理return WebUtils.isGatewayRequest() ?R.error(code, msg).requestId(MDC.get(Constant.REQUEST_ID_HEADER)): ResponseEntity.status(status).body(msg);}
}
(4)配置文件

SpringBoot的配置文件支持多环境配置,在天机学堂中也基于不同环境有不同配置文件:

说明:

文件

说明

bootstrap.yml

通用配置属性,包含服务名、端口、日志等等各环境通用信息

bootstrap-dev.yml

线上开发环境配置属性,虚拟机中部署使用

bootstrap-local.yml

本地开发环境配置属性,本地开发、测试、部署使用

项目中的很多共性的配置都放到了Nacos配置中心管理:

例如mybatismqredis等,都有对应的shared-xxx.yaml共享配置文件。在微服务中如果用到了相关技术,无需重复配置,只要引用上述共享配置即可:

①bootstrap.yml

我们来看看bootstrap.yml文件的基本内容:

②shared-spring.yml
spring:jackson:default-property-inclusion: non_null # 忽略json处理时的空值字段main:allow-bean-definition-overriding: true # 允许同名Bean重复定义mvc:pathmatch:# 解决异常:swagger Failed to start bean 'documentationPluginsBootstrapper'; nested exception is java.lang.NullPointerException# 因为Springfox使用的路径匹配是基于AntPathMatcher的,而Spring Boot 2.6.X使用的是PathPatternMatchermatching-strategy: ant_path_matcher
③shared-mybatis.yaml
mybatis-plus:configuration: # 默认的枚举处理器default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandlerglobal-config:field-strategy: 0 db-config:logic-delete-field: deleted # mybatis逻辑删除字段id-type: assign_id # 默认的id策略是雪花算法id
spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driver # 数据库驱动url: jdbc:mysql://${tj.jdbc.host:192.168.150.101}:${tj.jdbc.port:3306}/${tj.jdbc.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghaiusername: ${tj.jdbc.username:root}password: ${tj.jdbc.password:123}

注意到这里把mybatis的datasource都配置了,不过由于jdbc连接时的数据库ip、端口,数据库名、用户名、密码是不确定的,这里做了参数映射:

参数名

描述

默认值

tj.jdbc.host

主机名

192.168.150.101,也就是虚拟机ip

tj.jdbc.port

数据库端口

3306

tj.jdbc.database

数据库database名称

tj.jdbc.username

数据库用户名

root

tj.jdbc.password

数据库密码

123

除了 tj.jdbc.database外,其它参数都有默认值,在没有配置的情况下会按照默认值来配置,也可以按照参数名来自定义这些参数值。其中 tj.jdbc.database是必须自定义的值,例如在交易服务中:
tj:jdbc:database: tj_trade
④shared-mq.yaml
spring:rabbitmq:host: ${tj.mq.host:192.168.150.101} # mq的IPport: ${tj.mq.port:5672}virtual-host: ${tj.mq.vhost:/tjxt}username: ${tj.mq.username:tjxt}password: ${tj.mq.password:123321}listener:simple:retry:enabled: ${tj.mq.listener.retry.enable:true} # 开启消费者失败重试initial-interval: ${tj.mq.listener.retry.interval:1000ms} # 初始的失败等待时长为1秒multiplier: ${tj.mq.listener.retry.multiplier:1} # 失败的等待时长倍数,下次等待时长 = multiplier * last-intervalmax-attempts: ${tj.mq.listener.retry.max-attempts:3} # 最大重试次数stateless: ${tj.mq.listener.retry.stateless:true} # true无状态;false有状态。如果业务中包含事务,这里改为false

这里配置了mq的基本配置,例如地址、端口等,默认就是tjxt的地址,不需要修改。另外还配置类消费者的失败重试机制,如有需要可以按需修改。

⑤shared-redis.yaml
spring:redis:host: ${tj.redis.host:192.168.150.101}password: ${tj.redis.password:123321}lettuce:pool:max-active: ${tj.redis.pool.max-active:8}max-idle: ${tj.redis.pool.max-idle:8}min-idle: ${tj.redis.pool.min-idle:1}max-wait: ${tj.redis.pool.max-wait:300}

注意配置了Redis的基本地址和连接池配置,省去了我们大部分的工作

⑥shared-feign.yaml
feign:client:config:default: # default全局的配置loggerLevel: BASIC # 日志级别,BASIC就是基本的请求和响应信息httpclient:enabled: true # 开启feign对HttpClient的支持max-connections: 200 # 最大的连接数max-connections-per-route: 50 # 每个路径的最大连接数
⑦shared-xxljob.yaml
tj:xxl-job:access-token: tianjiadmin:address: http://192.168.150.101:8880/xxl-job-adminexecutor:appname: ${spring.application.name}log-retention-days: 10logPath: job/${spring.application.name}

这里配置了xxl-job组件的地址等信息,一般不需要修改。

2.远程调试

首先,我们需要对本地启动项做一些配置:

然后添加一个新的启动项:

在新建的Configuration中填写信息:

我们需要在启动时加上这段参数,像这样:

java -jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 xx.jar

不过我们的项目都是基于Jenkins来部署的,因此需要修改Jenkins部署脚本。部署脚本我也已经帮大家配置好了,我们直接运行即可:

部署完成后,可以看到tj-trade多暴露了一个5005端口,就是远程调试的端口了:

此时,就可以在启动项中看到我们配置的远程调试项目了:

最后测试得出:判断两个包装类的大小要使用equals,不要使用 == 和 !=。包装类为了提高性能,减少内存占用,采用了享元模式,提前将-128~127之间的Long包装类提前创建出来,共享使用。因此只要大小范围在此之间的数字,只要值相同,使用的都是享元模式中提供的同一个对象。如果判断的包装类在这个范围内就相等,不在这个范围内就不相等

3.测试部署

一般的测试步骤是这样的:

接口测试:

我们首先基于swagger做本地接口测试,在本地启动tj-trade项目,然后访问swagger页面:

http://localhost:8088/doc.html进行测试

组件测试:

在本地启动了某服务,而虚拟机中也启动了某服务,当我们请求网关时,如何保证请求一定进入本地启动的服务呢?

这里有两种办法:

  • 关停虚拟机中启动的交易服务

  • 将虚拟机中启动的交易服务权重设置为0

权重设置:

部署测试:

最后,测试没有问题,我们就可以将代码部署到开发环境去了。

我们在Jenkins中配置了web钩子,代码推送后自动触发构建。不过需要注意的是,默认情况下我们推送的代码不管是哪个分支都会触发构建,而且构建默认是基于lesson-init分支,需要重新配置。

我们找到Jenkins控制台中的tjxt-dev-build任务:

修改其中的配置。

第一个是哪些分支变化以后触发构建:

第二个是构建时基于哪个分支构建:

然后选择提交dev分支,并push到远端仓库:

然后到控制台,重新构建tj-trade服务:

再次测试即可。

二.我的课表

企业实际开发中,一般的流程是这样的:

需要强调的一点是,开发中最重要的环节其实是前两步:

  • 原型分析、接口设计

  • 数据库设计

(一)接口设计

天机学堂是一个开发中的项目,前期的需求分析已经完成,并且产品经理也设计出了产品原型,地址:

天机学堂-管理后台:https://lanhuapp.com/link/#/invite?sid=qx03viNU 密码: Ssml

天机学堂-用户端:https://lanhuapp.com/link/#/invite?sid=qx0Fy3fa 密码: ZsP3

我们可以基于产品原型来分析业务。

根据原型信息我们可以推测出《课程表》的业务流转过程是这样的:

与我的课表有关的接口有:

1. 分页查询我的课表

需求:在个人中心-我的课程页面,可以分页查询用户的课表及学习状态信息

返回值:

{ "total": 127,"totalPage":26,"list":[{"id": 0, // 课表id"courseId": 0, // 课程id"courseName": "", // 课程名称"courseCoverUrl": "", // 课程封面"createTime": "", // 加入课表时间"expireTime": "", // 过期时间"learnedSections": 0, // 已经学习小节数"sections": 0, // 总小节数"status": "", // 课程状态"weekFreq": 0 // 学习计划频率"planStatus": "", // 学习计划状态}]
}

与这个接口对应的,我们需要定义一下几个实体:

  • 统一的分页请求Query实体

  • 统一的分页结果DTO实体

  • 课表分页VO实体

由于分页请求、分页结果比较常见,我们提前在tj-common模块定义好了。

其中,统一分页请求实体,称为PageQuery:

统一分页结果实体,称为PageDTO:

课表VO:

package com.tianji.learning.domain.vo;import com.tianji.learning.enums.LessonStatus;
import com.tianji.learning.enums.PlanStatus;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;import java.time.LocalDateTime;@Data
@ApiModel(description = "课程表信息")
public class LearningLessonVO {@ApiModelProperty("主键lessonId")private Long id;@ApiModelProperty("课程id")private Long courseId;@ApiModelProperty("课程名称")private String courseName;@ApiModelProperty("课程封面")private String courseCoverUrl;@ApiModelProperty("课程章节数量")private Integer sections;@ApiModelProperty("课程状态,0-未学习,1-学习中,2-已学完,3-已失效")private LessonStatus status;@ApiModelProperty("总已学习章节数")private Integer learnedSections;@ApiModelProperty("总已报名课程数")private Integer courseAmount;@ApiModelProperty("课程购买时间")private LocalDateTime createTime;@ApiModelProperty("课程过期时间,如果为null代表课程永久有效")private LocalDateTime expireTime;@ApiModelProperty("学习计划状态,0-没有计划,1-计划进行中")private PlanStatus planStatus;@ApiModelProperty("计划的学习频率")private Integer weekFreq;@ApiModelProperty("最近学习的小节名")private String latestSectionName;@ApiModelProperty("最近学习的小节编号")private Integer latestSectionIndex;
}
package com.tianji.learning.enums;import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.tianji.common.enums.BaseEnum;
import lombok.Getter;@Getter
public enum LessonStatus implements BaseEnum {NOT_BEGIN(0, "未学习"),LEARNING(1, "学习中"),FINISHED(2, "已学完"),EXPIRED(3, "已过期"),;@JsonValue@EnumValueint value;String desc;LessonStatus(int value, String desc) {this.value = value;this.desc = desc;}@JsonCreator(mode = JsonCreator.Mode.DELEGATING)public static LessonStatus of(Integer value){if (value == null) {return null;}for (LessonStatus status : values()) {if (status.equalsValue(value)) {return status;}}return null;}
}
package com.tianji.learning.enums;import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.tianji.common.enums.BaseEnum;
import lombok.Getter;@Getter
public enum PlanStatus implements BaseEnum {NO_PLAN(0, "没有计划"),PLAN_RUNNING(1, "计划进行中"),;@JsonValue@EnumValueint value;String desc;PlanStatus(int value, String desc) {this.value = value;this.desc = desc;}@JsonCreator(mode = JsonCreator.Mode.DELEGATING)public static PlanStatus of(Integer value){if (value == null) {return null;}for (PlanStatus status : values()) {if (status.equalsValue(value)) {return status;}}return null;}
}

2. 添加课程到课程表

当用户支付完成或者报名免费课程后,应该立刻将课程加入到课表中。交易服务会通过MQ通知学习服务,我们需要查看交易服务的源码,查看MQ通知的消息格式,来确定监听消息的格式。

我们以免费报名课程为例来看:

trade-serviceOrderController中,有一个报名免费课程的接口:

可以看到这里调用了OrderServiceenrolledFreeCourse()方法:

其中,通知报名成功的逻辑是这部分:

由此,我们可以得知发送消息的Exchange、RoutingKey,以及消息体。消息体的格式是OrderBasicDTO,包含四个字段:

  • orderId:订单id

  • userId:下单的用户id

  • courseIds:购买的课程id集合

  • finishTime:支付完成时间

其中的请求参数实体,由于是与交易服务公用的数据传递实体,也就是DTO,因此已经提前定义到了tj-api模块下的DTO包了。

3.查询正在学习的课程

需求:在首页、个人中心-课程表页,需要查询并展示当前用户最近一次学习的课程

这里的返回值VO结构在之前定义的LearningLessonVO中都包含了,因此可以直接复用该VO,不再重复定义。

4.根据id查询指定课程的学习状态

需求:在课程详情页需要查询用户是否购买了指定课程,如果购买了则要返回学习状态信息

这里的返回值VO结构在之前定义的LearningLessonVO中都包含了,因此可以直接复用该VO,不再重复定义。

(二)数据结构

1.表结构

抽取业务实体-表结构和PO

表结构:

CREATE TABLE learning_lesson (id bigint NOT NULL COMMENT '主键',user_id bigint NOT NULL COMMENT '学员id',course_id bigint NOT NULL COMMENT '课程id',status tinyint DEFAULT '0' COMMENT '课程状态,0-未学习,1-学习中,2-已学完,3-已失效',week_freq tinyint DEFAULT NULL COMMENT '每周学习频率,每周3天,每天2节,则频率为6',plan_status tinyint NOT NULL DEFAULT '0' COMMENT '学习计划状态,0-没有计划,1-计划进行中',learned_sections int NOT NULL DEFAULT '0' COMMENT '已学习小节数量',latest_section_id bigint DEFAULT NULL COMMENT '最近一次学习的小节id',latest_learn_time datetime DEFAULT NULL COMMENT '最近一次学习的时间',create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',expire_time datetime NOT NULL COMMENT '过期时间',update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (id),UNIQUE KEY idx_user_id (user_id,course_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='学生课表';

我们要创建一个名为tj_learning的database,并且执行上面的SQL语句,创建learning_lesson表:

DROP TABLE IF EXISTS `learning_lesson`;
CREATE TABLE IF NOT EXISTS `learning_lesson` (`id` bigint NOT NULL COMMENT '主键',`user_id` bigint NOT NULL COMMENT '学员id',`course_id` bigint NOT NULL COMMENT '课程id',`status` tinyint DEFAULT '0' COMMENT '课程状态,0-未学习,1-学习中,2-已学完,3-已失效',`week_freq` tinyint DEFAULT NULL COMMENT '每周学习频率,例如每周学习6小节,则频率为6',`plan_status` tinyint NOT NULL DEFAULT '0' COMMENT '学习计划状态,0-没有计划,1-计划进行中',`learned_sections` int NOT NULL DEFAULT '0' COMMENT '已学习小节数量',`latest_section_id` bigint DEFAULT NULL COMMENT '最近一次学习的小节id',`latest_learn_time` datetime DEFAULT NULL COMMENT '最近一次学习的时间',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`expire_time` datetime DEFAULT NULL COMMENT '过期时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`) USING BTREE,UNIQUE KEY `idx_user_id` (`user_id`,`course_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学生课程表';DELETE FROM `learning_lesson`;
INSERT INTO `learning_lesson` (`id`, `user_id`, `course_id`, `status`, `week_freq`, `plan_status`, `learned_sections`, `latest_section_id`, `latest_learn_time`, `create_time`, `expire_time`, `update_time`) VALUES(1, 2, 2, 2, 6, 1, 12, 16, '2023-04-11 22:34:45', '2022-08-05 20:02:50', '2023-08-05 20:02:29', '2023-04-19 10:29:29'),(2, 2, 3, 1, 4, 1, 3, 31, '2023-04-19 11:42:50', '2022-08-06 15:16:48', '2023-08-06 15:16:37', '2023-04-19 11:42:50'),(1585170299127607297, 129, 2, 0, NULL, 0, 0, 16, '2023-04-11 22:37:05', '2022-12-05 23:00:29', '2023-10-26 15:14:54', '2023-04-11 22:37:05'),(1601061367207464961, 2, 1549025085494521857, 1, 3, 1, 4, 1550383240983875589, '2023-04-11 16:34:44', '2022-12-09 11:49:11', '2023-12-09 11:49:11', '2023-04-11 16:34:43');

2.创建分支

一般开发新功能都需要创建一个feature类型分支,不能在DEV分支直接开发,因此这里我们新建一个功能分支。我们在项目目录中打开terminal控制台,输入命令

git checkout -b feature-lessons

发现整个项目都切换到了新的功能分支:

3. 代码生成

在项目中,我们使用的是Mybatis作为持久层框架,并且引入了MybatisPlus来简化开发。因此,在创建据库以后,就需要创建对应的实体类、mapper、service等。

这些代码格式固定,编写起来又比较费时。好在IDEA中提供了一个MP插件,可以生成这些重复代码:

安装完成以后,我们先配置一下数据库地址:

特别注意,数据库名不要填写错误:

然后配置代码自动生成的设置:

严格按照下图的模式去设置(图片放大后更清晰),不要填错项目名称和包名称:

要注意的是,默认情况下PO的主键ID策略是自增长,而天机学堂项目默认希望采用雪花算法作为ID,因此这里需要对LearningLesson 的ID策略做修改:

除此之外,我们还要按照Restful风格,对请求路径做修改:

4.状态枚举

在数据结构中,课表是有学习状态的,学习计划也有状态:

这些状态如果每次编码都手写很容易写错,因此一般都会定义出枚举

在前面中已经提供了

这样以来,我们就需要修改PO对象,用枚举类型替代原本的Integer类型:

MybatisPlus会在我们与数据库交互时实现自动的数据类型转换,无需我们操心。

(三)实现接口功能

1. 添加课程到课表

在tj-learning服务中定义一个MQ的监听器:

代码如下:

@Slf4j
@Component
@RequiredArgsConstructor
public class LessonChangeListener {private final ILearningLessonService lessonService;@RabbitListener(bindings = @QueueBinding(value = @Queue(value = "learning.lesson.pay.queue",durable = "true"),exchange = @Exchange(name = MqConstants.Exchange.ORDER_EXCHANGE,type = ExchangeTypes.TOPIC),key = MqConstants.Key.ORDER_PAY_KEY))public void  listenLessonPay(OrderBasicDTO order){//1. 健壮性处理if(order == null || order.getOrderId() == null|| CollUtils.isEmpty(order.getCourseIds())){//数据有误,无需处理log.debug("接收到MQ消息有误,订单数据为空");return;}log.debug("监听到用户{}的订单{},需要添加课程{}到课表中",order.getUserId(),order.getOrderId(),order.getCourseIds());//2.添加课程lessonService.addUserLessons(order.getUserId(), order.getCourseIds());}
}

添加课程到课表流程:

这里添加课程的核心逻辑是在ILearningLessonService中实现的,首先是接口声明:

public interface ILearningLessonService extends IService<LearningLesson> {void addUserLessons(Long userId, List<Long> courseIds);
}

然后是对应的实现类:

@Service
@RequiredArgsConstructor
@Slf4j
public class LearningLessonServiceImpl extends ServiceImpl<LearningLessonMapper, LearningLesson> implements ILearningLessonService {private  final CourseClient courseClient;@Override@Transactionalpublic void addUserLessons(Long userId, List<Long> courseIds) {//查询课程有效期List<CourseSimpleInfoDTO> cInfoList=courseClient.getSimpleInfoList(courseIds);if(CollUtils.isEmpty(cInfoList)){//课程不存在,无法添加log.error("课程信息不存在,无法添加到课表");return;}//循环遍历List<LearningLesson> list=new ArrayList<>(cInfoList.size());for (CourseSimpleInfoDTO cInfo : cInfoList){LearningLesson lesson=new LearningLesson();//获取过期时间Integer validDuration = cInfo.getValidDuration();if(validDuration!=null&&validDuration>0){LocalDateTime now=LocalDateTime.now();lesson.setCreateTime( now);lesson.setExpireTime(now.plusMonths(validDuration));}//填充userId和courseIdlesson.setUserId(userId);lesson.setCourseId(cInfo.getId());list.add( lesson);}saveBatch(list);}
}

2.分页查询我的课表

(1)获取登录用户
①实现思路

既然是分页查询我的课表,除了分页信息以外,我还必须知道当前登录的用户是谁。那么,该从哪里获取用户信息呢?

天机学堂是基于JWT实现登录的,登录信息就保存在请求头的token中。因此要获取当前登录用户,只要获取请求头,解析其中的token即可。

但是,每个微服务都可能需要登录用户信息,在每个微服务都做token解析就属于重复编码了。因此我们的把token解析的行为放到了网关中,然后网关把用户信息放入请求头,传递给下游微服务。

每个微服务要从请求头拿出用户信息,在业务中使用,也比较麻烦,所以我们定义了一个HandlerInterceptor,拦截进入微服务的请求,并获取用户信息 ,存入UserContext(底层基于ThreadLocal)。这样后续的业务处理时就能直接从UserContext中获取用户了:

②网关鉴权

首先是网关登录校验、传递用户信息的逻辑,tj-gateway中的AccountAuthFilter

可以看到,网关将登录的用户信息放入请求头中传递到了下游的微服务。因此,我们只要在微服务中获取请求头,即可拿到登录的用户信息。

③用户信息上下文

然后是微服务中的获取请求头中的用户信息的拦截器。由于这个拦截器在每个微服务中都需要,与其重复编写,不如抽取到一个模块中。

所以在tj-auth模块中,有一个tj-auth-resource-sdk模块,已经把拦截器定义好了:

具体代码如下:

在这个拦截器中,获取到用户信息后保存到了UserContext中,这是一个基于ThreadLocal的工具,可以确保不同的请求之间互不干扰,避免线程安全问题发生:

登录信息传递的过程是这样的:

所以,以后在开发业务的时候,只需要在通过UserContext提供的getUser()方法就可以拿到用户id了。

(2)实现查询我的课表

首先是controller,tj-learning服务的LearningLessonController

@RestController
@RequestMapping("/lessons")
@Api(tags = "我的课表相关接口")
@RequiredArgsConstructor
public class LearningLessonController {private  final ILearningLessonService lessonService;@GetMapping("/page")@ApiOperation("分页查询我的课表")public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query) {return lessonService.queryMyLessons(query);}
}

需要注意的是,这里添加了Swagger相关注解,标记接口信息。

然后是service的接口,tj-learning服务的ILearningLessonService

PageDTO<LearningLessonVO> queryMyLessons(PageQuery query);

最后是实现类,tj-learning服务的LearningLessonServiceImpl:

@Override
public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query) {//1.获取当前登陆用户Long userId = UserContext.getUser();//2.分页查询//select * from learning_lesson where user_id = #{userId} order by //latest_learn_time limit 0, 5Page<LearningLesson> page=lambdaQuery().eq(LearningLesson::getUserId,userId).page(query.toMpPage("latest_learn_time", false));//获取结果集List<LearningLesson> records=page.getRecords();if(CollUtils.isEmpty(records)){ //返回空数据return PageDTO.empty(page);}//3.查询课程信息//3.1 获取课程idSet<Long> cIds = records.stream().map(LearningLesson::getCourseId).collect(Collectors.toSet());//3.2 查询课程信息List<CourseSimpleInfoDTO> cInfoList = courseClient.getSimpleInfoList(cIds);if(CollUtils.isEmpty(cInfoList)){//课程不存在,无法添加throw new BadRequestException("课程信息不存在!");}//3.3 把课程集合处理成Map,key是courseId,值是course本身Map<Long, CourseSimpleInfoDTO> cMap=cInfoList.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c));//4 封装VO返回List<LearningLessonVO> list=new ArrayList<>(records.size());//4.1 循环遍历,把LearningLesson转换为VOfor (LearningLesson r : records){//4.2 拷贝基础属性到voLearningLessonVO vo= BeanUtils.copyBean(r, LearningLessonVO.class);//4.3 获取课程信息,填充到voCourseSimpleInfoDTO cInfo = cMap.get(r.getCourseId());vo.setCourseName(cInfo.getName());vo.setCourseCoverUrl(cInfo.getCoverUrl());vo.setSections(cInfo.getSectionNum());list.add( vo);}return PageDTO.of(page, list);
}

3.查询正在学习的课程

(1)查询章节信息

小节名称、序号信息都在课程微服务(course-service)中,因此可以通过课程微服务提供的接口来查询:

其中CataSimpleInfoDTO中就包含了章节信息:

(2)代码实现

首先是controller,tj-learning服务的LearningLessonController

@GetMapping("/now")
@ApiOperation("查询我正在学习的课程")
public LearningLessonVO queryMyCurrentLesson() {return lessonService.queryMyCurrentLesson();
}

需要注意的是,这里添加了Swagger相关注解,标记接口信息。

然后是service的接口,tj-learning服务的ILearningLessonService

LearningLessonVO queryMyCurrentLesson();

最后是实现类,tj-learning服务的LearningLessonServiceImpl

private final CatalogueClient catalogueClient;@Override
public LearningLessonVO queryMyCurrentLesson() {// 1.获取当前登录的用户Long userId = UserContext.getUser();// 2.查询正在学习的课程 select * from xx where user_id = #{userId} AND status = 1 order by latest_learn_time limit 1LearningLesson lesson = lambdaQuery().eq(LearningLesson::getUserId, userId).eq(LearningLesson::getStatus, LessonStatus.LEARNING.getValue()).orderByDesc(LearningLesson::getLatestLearnTime).last("limit 1").one();if (lesson == null) {return null;}// 3.拷贝PO基础属性到VOLearningLessonVO vo = BeanUtils.copyBean(lesson, LearningLessonVO.class);// 4.查询课程信息CourseFullInfoDTO cInfo = courseClient.getCourseInfoById(lesson.getCourseId(), false, false);if (cInfo == null) {throw new BadRequestException("课程不存在");}vo.setCourseName(cInfo.getName());vo.setCourseCoverUrl(cInfo.getCoverUrl());vo.setSections(cInfo.getSectionNum());// 5.统计课表中的课程数量 select count(1) from xxx where user_id = #{userId}Integer courseAmount = lambdaQuery().eq(LearningLesson::getUserId, userId).count();vo.setCourseAmount(courseAmount);// 6.查询小节信息List<CataSimpleInfoDTO> cataInfos =catalogueClient.batchQueryCatalogue(CollUtils.singletonList(lesson.getLatestSectionId()));if (!CollUtils.isEmpty(cataInfos)) {CataSimpleInfoDTO cataInfo = cataInfos.get(0);vo.setLatestSectionName(cataInfo.getName());vo.setLatestSectionIndex(cataInfo.getCIndex());}return vo;
}

4.检查课程是否有效

这是一个微服务内部接口,当用户学习课程时,可能需要播放课程视频。此时提供视频播放功能的媒资系统就需要校验用户是否有播放视频的资格

具体参考tj-api模块下的com.tianji.api.client.learning.LearningClient接口:

接口实现:

@GetMapping("/{courdeId}/valid")
@ApiOperation("查询课程是否有效")
public Long isLessonValid(@PathVariable Long courdeId) {return lessonService.isLessonValid(courdeId);
}Long isLessonValid(Long courseId);@Override
public Long isLessonValid(Long courseId) {// 1.获取当前登录的用户Long userId = UserContext.getUser();//2.查询课表LearningLesson learn = lambdaQuery().eq(LearningLesson::getCourseId, courseId).eq(LearningLesson::getUserId, userId).one();if(learn==null){return null;}//3.判断课程是否过期LocalDateTime  expireTime = learn.getExpireTime();LocalDateTime now=LocalDateTime.now();if(expireTime!=null&&now.isAfter(expireTime)){return null;}return learn.getId();
}

5. 查询用户课表中指定课程状态

@GetMapping("/{courseId}")
@ApiOperation("查询用户课表中指定课程状态")
public LearningLessonVO queryLessonByCourseId(@PathVariable("courseId")Long courseId){return lessonService.queryLessonByCourseId(courseId);
}LearningLessonVO queryLessonByCourseId(Long courseId);@Override
public LearningLessonVO queryLessonByCourseId(Long courseId) {//获取用户Long userId = UserContext.getUser();//查询课表LearningLesson one = lambdaQuery().eq(LearningLesson::getCourseId, courseId).eq(LearningLesson::getUserId, userId).one();if (one == null) {return null;} else {return BeanUtils.copyBean(one, LearningLessonVO.class);}
}

6.统计课程的学习人数

课程微服务中需要统计每个课程的报名人数,同样是一个内部调用接口,在tj-api模块中已经定义好了:

实现:

//统计课程学习数量
@GetMapping("{courseId}/count")
@ApiOperation("统计课程学习人数")
public Integer countLearningLessonByCourse(@PathVariable("courseId") Long courseId) {return lessonService.countLearningLessonByCourse(courseId);
}Integer countLearningLessonByCourse(Long courseId);@Override
public Integer countLearningLessonByCourse(Long courseId) {return lambdaQuery().eq(LearningLesson::getCourseId, courseId).count();
}

7.删除课表中课程(没讲,自己做的)

删除课表中的课程有两种场景:

  • 用户直接删除已失效的课程

  • 用户退款后触发课表自动删除

其中用户退款与用户报名课程类似,都是基于MQ通知的方式。具体代码是在tj-trade模块的RefundApplySerivceImpl类的handleRefundResult方法中:

与报名成功的通知类似,一样是OrderBasicDTO,参数信息包含三个:

  • orderId:退款的订单id

  • userId:用户id

  • courseIds:退款的课程id

实现:

在LessonChangeListener中添加监听方法:

@RabbitListener(bindings = @QueueBinding(value = @Queue(value = "learning.lesson.refund.queue",durable = "true"),exchange = @Exchange(name = MqConstants.Exchange.ORDER_EXCHANGE,type = ExchangeTypes.TOPIC),key = MqConstants.Key.ORDER_REFUND_KEY
))
public void  listenLessonRefund(OrderBasicDTO order){//1.健壮性处理if(order==null||order.getOrderId()==null){log.debug("接收到MQ消息有误,订单数据为空");return;}log.debug("监听到用户{}的订单{},需要删除课程{}",order.getUserId(),order.getOrderId(),order.getCourseIds());//2.删除课程lessonService.removeToById(order.getCourseIds());
}

controller:

@DeleteMapping("/{courseId}")
@ApiOperation("删除用户课表中指定课程")
public void deleteLesson(@PathVariable("courseId")Long courseId){List<Long> courseIds = List.of(courseId);lessonService.removeToById(courseIds);
}

service:

void removeToById(List<Long> courseId);@Override
public void removeToById(List<Long> courseIds) {//获取用户Long userId = UserContext.getUser();//查询课程在课表是否存在LearningLesson lesson = lambdaQuery().eq(LearningLesson::getUserId, userId).eq(LearningLesson::getCourseId, courseIds.get(0)).one();if (lesson == null) {throw new BadRequestException("课程不存在");}//删除课表中的课程LambdaQueryChainWrapper<LearningLesson> eq = lambdaQuery().eq(LearningLesson::getUserId, userId).eq(LearningLesson::getCourseId, courseIds);remove(eq);
}

三.学习计划和进度

(一)分析产品原型

1. 分析业务流程

2.设计业务接口

(1)提交学习记录

需求:在课程学习页面播放视频时或考试后,需要提交学习记录信息到服务端保存。

(2)根据id查询指定课程的学习记录

在课程学习页面需要查询出每一个小节的基本信息,以及小节对应的学习记录

(3)创建学习计划

需求:当用户点击创建学习计划时,会提交课程信息courseId和计划的学习频率 weekFreq到服务端。服务端需要将数据写入对应的课表中。

(4)查询最近正在学习的课程学习计划

需求:在我的课程页面中,需要统计用户本周的学习计划及进度,数据较多,采用分页查询

3.设计数据库

CREATE DATABASE IF NOT EXISTS `tj_learning` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `tj_learning`;CREATE TABLE IF NOT EXISTS `learning_record` (`id` bigint NOT NULL COMMENT '学习记录的id',`lesson_id` bigint NOT NULL COMMENT '对应课表的id',`section_id` bigint NOT NULL COMMENT '对应小节的id',`user_id` bigint NOT NULL COMMENT '用户id',`moment` int DEFAULT '0' COMMENT '视频的当前观看时间点,单位秒',`finished` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否完成学习,默认false',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '第一次观看时间',`finish_time` datetime DEFAULT NULL COMMENT '完成学习的时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间(最近一次观看时间)',PRIMARY KEY (`id`) USING BTREE,KEY `idx_update_time` (`update_time`) USING BTREE,KEY `idx_user_id` (`user_id`) USING BTREE,KEY `idx_lesson_id` (`lesson_id`,`section_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学习记录表';DELETE FROM `learning_record`;
INSERT INTO `learning_record` (`id`, `lesson_id`, `section_id`, `user_id`, `moment`, `finished`, `create_time`, `finish_time`, `update_time`) VALUES(1582555272977592322, 1, 16, 2, 239, b'1', '2022-10-19 10:12:34', '2022-10-19 10:45:55', '2022-10-19 15:50:12'),(1582565729037791233, 1, 17, 2, 148, b'1', '2022-10-19 10:54:07', '2022-10-19 10:57:47', '2022-10-19 15:50:05'),(1582572518466760706, 1, 18, 2, 14, b'1', '2022-10-19 11:21:06', '2022-10-19 14:52:50', '2022-10-27 12:05:14'),(1582572534866489346, 1, 19, 2, 250, b'1', '2022-10-19 11:21:10', '2022-10-19 17:53:55', '2022-10-19 17:56:01'),(1582572535164284930, 1, 20, 2, 135, b'1', '2022-10-19 11:21:10', '2022-10-19 17:58:09', '2022-10-27 12:07:12'),(1582572537429209089, 1, 21, 2, 253, b'1', '2022-10-19 11:21:10', '2022-10-19 18:02:34', '2022-10-19 18:04:54'),(1582674101338656770, 1, 22, 2, 257, b'1', '2022-10-19 18:04:45', '2022-10-19 18:07:15', '2022-10-19 18:09:35'),(1582675262523326466, 1, 23, 2, 254, b'1', '2022-10-19 18:09:22', '2022-10-19 18:11:37', '2022-10-19 18:13:57'),(1582676374013886465, 1, 24, 2, 250, b'1', '2022-10-19 18:13:47', '2022-10-19 18:16:02', '2022-10-20 11:36:50'),(1582938844335001602, 1, 25, 2, 80, b'1', '2022-10-20 11:36:44', '2022-10-20 11:38:59', '2022-10-20 11:58:00'),(1583012729738776577, 1, 26, 2, 262, b'1', '2022-10-20 16:30:20', '2022-10-20 16:30:21', '2022-10-20 16:38:39'),(1586757474101342209, 1, 27, 2, 0, b'1', '2022-10-31 00:30:37', '2022-10-31 00:30:36', '2022-10-31 00:30:37'),(1586757474101342309, 2, 29, 2, 10, b'0', '2022-10-31 00:30:37', NULL, '2022-10-31 00:30:37'),(1599780755855228929, 1585170299127607297, 16, 129, 37, b'0', '2022-12-05 23:00:29', NULL, '2022-12-05 23:01:34');

4.生成基础代码

动手之前,不要忘了开发新功能需要创建新的分支。这里我们依然在DEV分支基础上,创建一个新的feature类型分支:feature-learning-records

代码生成:

同样是使用MybatisPlus插件

需要注意的是,我们同样需要把生成的实体类的ID策略改成雪花算法:

另外,按照Restful风格, 把controller的路径做修改:

类型枚举:

在学习记录中,有一个section_type字段,代表记录的小节有两种类型:

  • 1,视频类型

  • 2,考试类型

为了方便我们也定义为枚举,称为类型枚举:

package com.tianji.learning.enums;import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.tianji.common.enums.BaseEnum;
import lombok.Getter;@Getter
public enum SectionType implements BaseEnum {VIDEO(1, "视频"),EXAM(2, "考试"),;@JsonValue@EnumValueint value;String desc;SectionType(int value, String desc) {this.value = value;this.desc = desc;}@JsonCreator(mode = JsonCreator.Mode.DELEGATING)public static SectionType of(Integer value){if (value == null) {return null;}for (SectionType status : values()) {if (status.equalsValue(value)) {return status;}}return null;}
}

(二)实现接口

1. 根据id查询指定课程的学习记录

这个接口是给课程微服务调用的,因此在tj-api模块的LearningClient中定义好了

/*** 查询当前用户指定课程的学习进度* @param courseId 课程id* @return 课表信息、学习记录及进度信息*/
@GetMapping("/learning-records/course/{courseId}")
LearningLessonDTO queryLearningRecordByCourse(@PathVariable("courseId") Long courseId);

首先在tj-learning模块下的com.tianji.learning.controller.LearningRecordController下定义接口:

@RestController
@RequestMapping("/learning-records")
@Api(tags = "学习记录接口")
@RequiredArgsConstructor
public class LearningRecordController {private final  ILearningRecordService learningRecordService;@ApiOperation("查询指定课程的学习记录")@GetMapping("/course/{courseId}")public LearningLessonDTO queryLearningRecordByCourse(@ApiParam(value = "课程id", example = "2 ")@PathVariable("courseId") Long courseId) {return learningRecordService.queryLearningRecordByCourse(courseId);}
}

然后在com.tianji.learning.service.ILearningRecordService中定义方法,在com.tianji.learning.service.impl.LearningRecordServiceImpl中定义实现类:

public interface ILearningRecordService extends IService<LearningRecord> {LearningLessonDTO queryLearningRecordByCourse(Long courseId);
}@Service
@RequiredArgsConstructor
public class LearningRecordServiceImpl extends ServiceImpl<LearningRecordMapper, LearningRecord> implements ILearningRecordService {ILearningLessonService lessonService;@Overridepublic LearningLessonDTO queryLearningRecordByCourse(Long courseId) {//1. 获取登录用户Long userId = UserContext.getUser();//2. 查询课表LearningLesson lesson = lessonService.queryByUserIdAndCourseId(userId, courseId);if (lesson == null) {return null;}//3. 查询学习记录//select * from learning_record where lesson_id = #{}?List<LearningRecord> records = lambdaQuery().eq(LearningRecord::getLessonId, lesson.getId()).list();//封装结果LearningLessonDTO dto = new LearningLessonDTO();dto.setId(lesson.getId());dto.setLatestSectionId(lesson.getLatestSectionId());dto.setRecords(BeanUtils.copyList(records, LearningRecordDTO.class));return dto;}
}

其中查询课表的时候,需要调用ILessonService中的queryByUserIdAndCourseId()方法,该方法代码如下:

LearningLesson queryByUserIdAndCourseId(Long userId, Long courseId);@Override
public LearningLesson queryByUserIdAndCourseId(Long userId, Long courseId) {return lambdaQuery().eq(LearningLesson::getCourseId, courseId).eq(LearningLesson::getUserId, userId).one();
}

2. 提交学习记录

表单DTO实体:

@Data
@ApiModel(description = "学习记录")
public class LearningRecordFormDTO {@ApiModelProperty("小节类型:1-视频,2-考试")@NotNull(message = "小节类型不能为空")@EnumValid(enumeration = {1, 2}, message = "小节类型错误,只能是:1-视频,2-考试")private SectionType sectionType;@ApiModelProperty("课表id")@NotNull(message = "课表id不能为空")private Long lessonId;@ApiModelProperty("对应节的id")@NotNull(message = "节的id不能为空")private Long sectionId;@ApiModelProperty("视频总时长,单位秒")private Integer duration;@ApiModelProperty("视频的当前观看时长,单位秒,第一次提交填0")private Integer moment;@ApiModelProperty("提交时间")private LocalDateTime commitTime;
}
@Getter
public enum PlanStatus implements BaseEnum {NO_PLAN(0, "没有计划"),PLAN_RUNNING(1, "计划进行中"),;@JsonValue@EnumValueint value;String desc;PlanStatus(int value, String desc) {this.value = value;this.desc = desc;}@JsonCreator(mode = JsonCreator.Mode.DELEGATING)public static PlanStatus of(Integer value){if (value == null) {return null;}for (PlanStatus status : values()) {if (status.equalsValue(value)) {return status;}}return null;}
}

代码实现:

首先在tj-learning模块下的com.tianji.learning.controller.LearningRecordController下定义接口:

@ApiOperation("提交学习记录")
@PostMapping
public void addLearningRecord(@RequestBody LearningRecordFormDTO formDTO) {learningRecordService.addLearningRecord(formDTO);
}

然后在com.tianji.learning.service.ILearningRecordService中定义方法,并在com.tianji.learning.service.impl.LearningRecordServiceImpl中定义实现类:

void addLearningRecord(LearningRecordFormDTO formDTO);@Override
@Transactional
public void addLearningRecord(LearningRecordFormDTO recordDto) {//1. 获取登陆用户Long userId = UserContext.getUser();//2. 处理学习记录boolean finished = false;if (recordDto.getSectionType() == SectionType.VIDEO) {//2.1 处理视频finished = handleVideoRecord(userId, recordDto);} else {//2.1 处理考试finished = handleExamRecord(userId, recordDto);}//3. 处理课表数据handleLearningLessonsChanges(recordDto, finished);
}private void handleLearningLessonsChanges(LearningRecordFormDTO recordDto, 
boolean finished) {//1. 查询课表LearningLesson lesson = lessonService.getById(recordDto.getLessonId());if (lesson==null){throw  new BizIllegalException("课程不存在,无法更新数据!");}//2.判断是否有新的完成小节boolean allLearned=false;if (finished){//3. 如果有新完成的小节,则需要查询课程数据CourseFullInfoDTO cInfo = courseClient.getCourseInfoById(lesson.getCourseId(), false, false);if (cInfo==null){throw  new BizIllegalException("课程不存在,无法更新数据!");}//4. 比较课程是否全部学完:已学习小节>=课程总小节allLearned = lesson.getLearnedSections() +1>= cInfo.getSectionNum();}//更新课表lessonService.lambdaUpdate().set(lesson.getLearnedSections()==0,LearningLesson::getStatus, LessonStatus.LEARNING.getValue()).set(allLearned,LearningLesson::getStatus,LessonStatus.FINISHED.getValue()).set(!finished,LearningLesson::getLatestSectionId,recordDto.getSectionId()).set(!finished,LearningLesson::getLatestLearnTime,recordDto.getCommitTime()).setSql(finished,"learned_sections = learned_sections+1").eq(LearningLesson::getId, lesson.getId()).update();
}private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDto) {//1. 查询旧的学习记录LearningRecord old = lambdaQuery().eq(LearningRecord::getSectionId, recordDto.getSectionId()).eq(LearningRecord::getLessonId, recordDto.getLessonId()).one();//2.判断是否存在if (old == null) {//3. 不存在,则新增//3.1 转换poLearningRecord learningRecord = BeanUtils.copyBean(recordDto, LearningRecord.class);//3.2. 填充数据learningRecord.setUserId(userId);//3.写入数据库boolean success = save(learningRecord);if (!success) {throw new DbException("新增学习记录失败");}return false;}//4.存在,则更新//4.1 判断是否是第一次完成boolean finished=!old.getFinished()&&recordDto.getMoment()*2>=recordDto.getDuration();//4.2 更新数据boolean success= lambdaUpdate().set(LearningRecord::getMoment, recordDto.getMoment()).set(finished, LearningRecord::getFinished, true).set(finished, LearningRecord::getFinishTime, recordDto.getCommitTime()).eq(LearningRecord::getId, old.getId()).update();if(!success){throw new DbException("更新学习记录失败");}return finished;
}private boolean handleExamRecord(Long userId, LearningRecordFormDTO recordDto) {//1. 转换DTO为POLearningRecord learningRecord = BeanUtils.copyBean(recordDto, LearningRecord.class);//2. 填充数据learningRecord.setUserId(userId);learningRecord.setFinished(true);learningRecord.setFinishTime(recordDto.getCommitTime());//3.写入数据库boolean success = save(learningRecord);if (!success) {throw new DbException("新增考试记录失败");}return true;
}

3. 创建学习计划

表单实体:

@Data
@ApiModel(description = "学习计划表单实体")
public class LearningPlanDTO {@NotNull@ApiModelProperty("课程表id")@Min(1)private Long courseId;@NotNull@Range(min = 1, max = 50)@ApiModelProperty("每周学习频率")private Integer freq;
}

代码实现:

首先,在com.tianji.learning.controller.LearningLessonController中添加一个接口:

@ApiOperation("创建学习计划")
@PostMapping("/plans")
public void createLearningPlans(@Valid @RequestBody LearningPlanDTO planDTO){lessonService.createLearningPlans(planDTO);
}

然后,在com.tianji.learning.service.ILearningLessonService中定义service方法,在com.tianji.learning.service.impl.LearningLessonServiceImpl中实现方法:

void createLearningPlans(LearningPlanDTO planDTO);@Override
public void createLearningPlans(LearningPlanDTO planDTO) {//1.获取当前登录的用户Long userId = UserContext.getUser();//2. 查询课表中指定课程有关的数据LearningLesson lesson = queryByUserIdAndCourseId(userId, planDTO.getCourseId());if (lesson == null) {throw new BadRequestException("课程信息不存在");}//3.修改数据lesson.setPlanStatus(PlanStatus.PLAN_RUNNING);lesson.setWeekFreq(planDTO.getFreq());updateById(lesson);
}

4.查询学习计划

实体:

@EqualsAndHashCode(callSuper = true)
@Data
@ApiModel(description = "学习计划分页统计结果")
public class LearningPlanPageVO extends PageDTO<LearningPlanVO> {@ApiModelProperty("本周积分值")private Integer weekPoints;@ApiModelProperty("本周完成的计划数量")private Integer weekFinished;@ApiModelProperty("总的计划学习数量")private Integer weekTotalPlan;public LearningPlanPageVO() {}public LearningPlanPageVO pageInfo(Long total, Long pages, List<LearningPlanVO> list) {this.total = total;this.pages = pages;this.list = list;return this;}public LearningPlanPageVO pageInfo(PageDTO<LearningPlanVO> pageDTO) {this.total = pageDTO.getTotal();this.pages = pageDTO.getPages();this.list = pageDTO.getList();return this;}}
@Data
@ApiModel(description = "课程计划信息")
public class LearningPlanVO {@ApiModelProperty("主键lessonId")private Long id;@ApiModelProperty("课程id")private Long courseId;@ApiModelProperty("课程名称")private String courseName;@ApiModelProperty("每周计划学习章节数")private Integer weekFreq;@ApiModelProperty("课程章节数量")private Integer sections;@ApiModelProperty("本周已学习章节数")private Integer weekLearnedSections;@ApiModelProperty("总已学习章节数")private Integer learnedSections;@ApiModelProperty("最近一次学习时间")private LocalDateTime latestLearnTime;
}

代码实现:

首先在tj-learning模块的com.tianji.learning.controller.LearningLessonController中定义controller接口:

@ApiOperation("查询我的学习计划")
@GetMapping("/plans")
public LearningPlanPageVO queryMyPlans(PageQuery query){return lessonService.queryMyPlans(query);
}

然后在com.tianji.learning.service.ILearningLessonService中定义service方法:

LearningPlanPageVO queryMyPlans(PageQuery query);

最后在com.tianji.learning.service.impl.LearningLessonServiceImpl中实现该方法:

private final CourseClient courseClient;
private final CatalogueClient catalogueClient;
private final LearningRecordMapper learningRecordMapper;@Override
public LearningPlanPageVO queryMyPlans(PageQuery query) {LearningPlanPageVO result = new LearningPlanPageVO();//1.获取当前登陆的用户Long userId= UserContext.getUser();//2.获取本周起始时间LocalDate now=LocalDate.now();LocalDateTime  begin= DateUtils.getWeekBeginTime(now);LocalDateTime end=DateUtils.getWeekEndTime(now);//3. 查询中的统计数据//3.1 本周总的已学习的小节数量LambdaQueryWrapper<LearningRecord> wrapper=new LambdaQueryWrapper<>();LambdaQueryWrapper<LearningRecord> lt = wrapper.eq(LearningRecord::getUserId, userId).eq(LearningRecord::getFinished, true).gt(LearningRecord::getFinishTime, begin).lt(LearningRecord::getFinishTime, end);Integer weekFinished = learningRecordMapper.selectCount(lt);result.setWeekFinished(weekFinished);//3.2 本周总的计划学习小节数量Integer weekTotalPlan= getBaseMapper().queryTotalPlan(userId);result.setWeekTotalPlan(weekTotalPlan);//todo 3.3本周学习积分//4.查询分页数据//4.1.分页查询课表信息以及学习计划信息Page<LearningLesson> p= lambdaQuery().eq(LearningLesson::getUserId, userId).eq(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING).in(LearningLesson::getStatus, LessonStatus.NOT_BEGIN, LessonStatus.LEARNING).page(query.toMpPage("latest_learn_time", false));List<LearningLesson> records=p.getRecords();if(CollUtils.isEmpty(records)){return result;}//4.2.查询课表对应的课程信息Map<Long, CourseSimpleInfoDTO> cMap = queryCourseSimpleInfoList(records);//4.3.统计每一个课程本周已学习小节数量List<IdAndNumDTO> list=learningRecordMapper.countLearnedSections(userId,begin,end);Map<Long, Integer> countMap = IdAndNumDTO.toMap(list);//4.4.组装数据voList<LearningPlanVO> voList=new ArrayList<>(records.size());for (LearningLesson r:records){//4.4 1.拷贝基础属性到voLearningPlanVO vo= BeanUtils.copyBean(r,LearningPlanVO.class);//4.2 2.填充课程详细信息CourseSimpleInfoDTO cInfo=cMap.get(r.getCourseId());if(cInfo!=null){vo.setCourseName(cInfo.getName());vo.setSections(cInfo.getSectionNum());}//4.3 3.每个课程的本周已学习小节数量vo.setWeekLearnedSections(countMap.getOrDefault(r.getId(),0));voList.add(vo);}return result.pageInfo(p.getTotal(),p.getPages(),voList);
}private Map<Long, CourseSimpleInfoDTO> queryCourseSimpleInfoList
(List<LearningLesson> records) {//3.1 获取课程idSet<Long> cIds = records.stream().map(LearningLesson::getCourseId).collect(Collectors.toSet());//3.2 查询课程信息List<CourseSimpleInfoDTO> cInfoList = courseClient.getSimpleInfoList(cIds);if (CollUtils.isEmpty(cInfoList)) {//课程不存在,无法添加throw new BadRequestException("课程信息不存在!");}//3.3 把课程集合处理成Map,key是courseId,值是course本身Map<Long, CourseSimpleInfoDTO> cMap = cInfoList.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c));return cMap;
}

LearningLessonMapper:

public interface LearningLessonMapper extends BaseMapper<LearningLesson> {@Select("select sum(week_freq) from learning_lesson where user_id=#{userId}  and plan_status=1 and status in (0,1)")Integer queryTotalPlan(@Param("userId") Long userId);
}

其中需要调用LearningRecordMapper实现对本周每个课程的已学习小节的统计,对应实现如下:

List<IdAndNumDTO> countLearnedSections(@Param("userId") Long userId,@Param("begin")LocalDateTime begin,@Param("end") LocalDateTime end);

LearningRecordMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tianji.learning.mapper.LearningRecordMapper"><select id="countLearnedSections" resultType="com.tianji.api.dto.IdAndNumDTO">select lesson_id as id,count(1) as numfrom learning_recordwhere user_id = #{userId}and finished=1and finish_time &gt;#{begin} and finish_time &lt;#{end}group by lesson_id</select>
</mapper>

四. 高并发优化

(一)高并发优化方案

解决高并发问题从宏观角度来说有3个方向:

其中,水平扩展和服务保护侧重的是运维层面的处理。而提高单机并发能力侧重的则是业务层面的处理,也就是我们程序员在开发时可以做到的。

因此,我们重点讨论如何通过编码来提供业务的单机并发能力。

1.单机并发能力

在机器性能一定的情况下,提高单机并发能力就是要尽可能缩短业务的响应时间(ResponseTime),而对响应时间影响最大的往往是对数据库的操作。而从数据库角度来说,我们的业务无非就是两种类型。

对于读多写少的业务,其优化手段大家都比较熟悉了,主要包括两方面:

  • 优化代码和SQL

  • 添加缓存

对于写多读少的业务,大家可能较少碰到,优化的手段可能也不太熟悉,这也是我们要讲解的重点。对于高并发写的优化方案有:

  • 优化代码及SQL

  • 变同步写为异步写

  • 合并写请求

代码和SQL优化与读优化类似,我们就不再赘述了,接下来我们着重分析一下变同步为异步、合并写请求两种优化方案。

2. 变同步为异步

假如一个业务比较复杂,需要有多次数据库的写业务,如图所示:

由于各个业务之间是同步串行执行,因此整个业务的响应时间就是每一次数据库写业务的响应时间之和,并发能力肯定不会太好。

优化的思路很简单,利用MQ可以把同步业务变成异步,从而提高效率。

  • 当我们接收到用户请求后,可以先不处理业务,而是发送MQ消息并返回给用户结果。

  • 而后通过消息监听器监听MQ消息,处理后续业务。

如图:

这样一来,用户请求处理和后续数据库写就从同步变为异步,用户无需等待后续的数据库写操作,响应时间自然会大大缩短。并发能力自然大大提高。

优点

  • 无需等待复杂业务处理,大大减少响应时间

  • 利用MQ暂存消息,起到流量削峰整形作用

  • 降低写数据库频率,减轻数据库并发压力

缺点

  • 依赖于MQ的可靠性

  • 降低了些频率,但是没有减少数据库写次数

应用场景

  • 比较适合应用于业务复杂, 业务链较长,有多次数据库写操作的业务。

3.合并写请求

合并写请求方案其实是参考高并发读的优化思路:当读数据库并发较高时,我们可以把数据缓存到Redis,这样就无需访问数据库,大大减少数据库压力,减少响应时间。

既然读数据可以建立缓存,那么写数据可以不可以也缓存到Redis呢?

答案是肯定的,合并写请求就是指当写数据库并发较高时,不再直接写到数据库。而是先将数据缓存到Redis,然后定期将缓存中的数据批量写入数据库。

如图:

由于Redis是内存操作,写的效率也非常高,这样每次请求的处理速度大大提高,响应时间大大缩短,并发能力肯定有很大的提升。

而且由于数据都缓存到Redis了,积累一些数据后再批量写入数据库,这样数据库的写频率、写次数都大大减少,对数据库压力小了非常多!

优点:

  • 写缓存速度快,响应时间大大减少

  • 降低数据库的写频率和写次数,大大减轻数据库压力

缺点:

  • 实现相对复杂

  • 依赖Redis可靠性

  • 不支持事务和复杂业务

场景:

  • 写频率较高、写业务相对简单的场景

(二)播放进度记录方案改进

1.优化方案设计

考试:每章只能考一次,还不能重复考试。因此属于低频行为,可以忽略

视频进度:前端每隔15秒就提交一次请求。在一个视频播放的过程中,可能有数十次请求,但完播(进度超50%)的请求只会有一次。因此多数情况下都是更新一下播放进度即可。

也就是说,95%的请求都是在更新learning_record表中的moment字段,以及learning_lesson表中的正在学习的小节id和时间。

而播放进度信息,不管更新多少次,下一次续播肯定是从最后的一次播放进度开始续播。也就是说我们只需要记住最后一次即可。因此可以采用合并写方案来降低数据库写的次数和频率,而异步写做不到。

综上,提交播放进度业务虽然看起来复杂,但大多数请求的处理很简单,就是更新播放进度。并且播放进度数据是可以合并的(覆盖之前旧数据)。我们建议采用合并写请求方案:

2.Redis数据结构设计

我们的优化方案要处理的不是所有的提交学习记录请求。仅仅是视频播放时的高频更新播放进度的请求

这条业务支线的流程如下:

  • 查询播放记录,判断是否存在

    • 如果不存在,新增一条记录

    • 如果存在,则更新学习记录

  • 判断当前进度是否是第一次学完

    • 播放进度要超过50%

    • 原本的记录状态是未学完

  • 更新课表中最近学习小节id、学习时间

这里有多次数据库操作,例如:

  • 查询播放记录:需要知道播放记录是否存在、播放记录当前的完成状态

  • 更新播放记录:更新播放进度

  • 更新最近学习小节id、时间

一方面我们要缓存写数据,减少写数据库频率;另一方面我们要缓存播放记录,减少查询数据库。因此,缓存中至少要包含3个字段:

  • 记录id:id,用于根据id更新数据库

  • 播放进度:moment,用于缓存播放进度

  • 播放状态(是否学完):finished,用于判断是否是第一次学完

既然一个小节要保存多个字段,是不是可以考虑使用Hash结构来保存这些数据,如图:

不过,这样设计有一个问题。课程有很多,每个课程的小节也非常多。每个小节都是一个独立的KEY,需要创建的KEY也会非常多,浪费大量内存。

而且,用户学习视频的过程中,可能会在多个视频之间来回跳转,这就会导致频繁的创建缓存、缓存过期,影响到最终的业务性能。该如何解决呢?

既然一个课程包含多个小节,我们完全可以把一个课程的多个小节作为一个KEY来缓存,如图:

KEY

HashKey

HashValue

lessonId

sectionId:1

{ "id": 1, "moment": 242, "finished": true }

sectionId:2

{ "id": 2, "moment": 20, "finished": false }

sectionId:3

{ "id": 3, "moment": 121, "finished": false }

这样做有两个好处:

  • 可以大大减少需要创建的KEY的数量,减少内存占用。

  • 一个课程创建一个缓存,当用户在多个视频间跳转时,整个缓存的有效期都会被延续,不会频繁的创建和销毁缓存数据

添加缓存以后,学习记录提交的业务流程就需要发生一些变化了,如图:

变化最大的有两点:

  • 提交播放进度后,如果是更新播放进度则不写数据库,而是写缓存

  • 需要一个定时任务,定期将缓存数据写入数据库

变化后的业务具体流程为:

  • 1.提交学习记录

  • 2.判断是否是考试

    • 是:新增学习记录,并标记有小节被学完。走步骤8

    • 否:走视频流程,步骤3

  • 3.查询播放记录缓存,如果缓存不存在则查询数据库并建立缓存

  • 4.判断记录是否存在

    • 4.1.否:新增一条学习记录

    • 4.2.是:走更新学习记录流程,步骤5

  • 5.判断是否是第一次学完(进度超50%,旧的状态是未学完)

    • 5.1.否:仅仅是要更新播放进度,因此直接写入Redis并结束

    • 5.2.是:代表小节学完,走步骤6

  • 6.更新学习记录状态为已学完

  • 7.清理Redis缓存:因为学习状态变为已学完,与缓存不一致,因此这里清理掉缓存,这样下次查询时自然会更新缓存,保证数据一致。

  • 8.更新课表中已学习小节的数量+1

  • 9.判断课程的小节是否全部学完

    • 是:更新课表状态为已学完

    • 否:结束

3.持久化思路

对于合并写请求方案,一定有一个步骤就是持久化缓存数据到数据库。一般采用的是定时任务持久化:

但是定时任务的持久化方式在播放进度记录业务中存在一些问题,主要就是时效性问题。我们的产品要求视频续播的时间误差不能超过30秒。

  • 假如定时任务间隔较短,例如20秒一次,对数据库的更新频率太高,压力太大

  • 假如定时任务间隔较长,例如2分钟一次,更新频率较低,续播误差可能超过2分钟,不满足需求

注意

如果产品对于时间误差要求不高,定时任务处理是最简单,最可靠的一种方案,推荐大家使用。

假如一个视频时长为20分钟,我们从头播放至15分钟关闭,每隔15秒提交一次播放进度,大概需要提交60次请求。

但是下一次我们再次打开该视频续播的时候,肯定是从最后一次提交的播放进度来续播。也就是说续播进度之前的N次播放进度都是没有意义的,都会被覆盖。

既然如此,我们完全没有必要定期把这些播放进度写到数据库,只需要将用户最后一次提交的播放进度写入数据库即可。

只要用户一直在提交记录,Redis中的播放进度就会一直变化。如果Redis中的播放进度不变,肯定是停止了播放,是最后一次提交。

因此,我们只要能判断Redis中的播放进度是否变化即可。怎么判断呢?

每当前端提交播放记录时,我们可以设置一个延迟任务并保存这次提交的进度。等待20秒后(因为前端每15秒提交一次,20秒就是等待下一次提交),检查Redis中的缓存的进度与任务中的进度是否一致。

  • 不一致:说明持续在提交,无需处理

  • 一致:说明是最后一次提交,更新学习记录、更新课表最近学习小节和时间到数据库中

流程如下:

(三)延迟任务

1.延迟任务方案

延迟任务的实现方案有很多,常见的有四类:

DelayQueue

Redisson

MQ

时间轮

原理

JDK自带延迟队列,基于阻塞队列实现。

基于Redis数据结构模拟JDK的DelayQueue实现

利用MQ的特性。例如RabbitMQ的死信队列

时间轮算法

优点

  • 不依赖第三方服务

  • 分布式系统下可用

  • 不占用JVM内存

  • 分布式系统下可以

  • 不占用JVM内存

  • 不依赖第三方服务

  • 性能优异

缺点

  • 占用JVM内存

  • 只能单机使用

  • 依赖第三方服务

  • 依赖第三方服务

  • 只能单机使用

以上四种方案都可以解决问题,不过本例中我们会使用DelayQueue方案。因为这种方案使用成本最低,而且不依赖任何第三方服务,减少了网络交互。

但缺点也很明显,就是需要占用JVM内存,在数据量非常大的情况下可能会有问题。但考虑到任务存储时间比较短(只有20秒),因此也可以接收。

如果 数据量非常大,DelayQueue不能满足业务需求,大家也可以替换为其它延迟队列方式,例如Redisson、MQ等

2.DelayQueue的原理

首先来看一下DelayQueue的源码:

public class DelayQueue<E extends Delayed> extends AbstractQueue<E>implements BlockingQueue<E> {private final transient ReentrantLock lock = new ReentrantLock();private final PriorityQueue<E> q = new PriorityQueue<E>();// ... 略
}

可以看到DelayQueue实现了BlockingQueue接口,是一个阻塞队列。队列就是容器,用来存储东西的。DelayQueue叫做延迟队列,其中存储的就是延迟执行的任务

我们可以看到DelayQueue的泛型定义:

DelayQueue<E extends Delayed>

这说明存入DelayQueue内部的元素必须是Delayed类型,这其实就是一个延迟任务的规范接口。来看一下:

public interface Delayed extends Comparable<Delayed> {long getDelay(TimeUnit unit);
}
public interface Comparable<T> {public int compareTo(T o);
}

从源码中可以看出,Delayed类型必须具备两个方法:

  • getDelay():获取延迟任务的剩余延迟时间

  • compareTo(T t):比较两个延迟任务的延迟时间,判断执行顺序

可见,Delayed类型的延迟任务具备两个功能:获取剩余延迟时间、比较执行顺序。当然,我们可以对Delayed做实现和功能扩展,比如添加延迟任务的数据。

将来每一次提交播放记录,就可以将播放记录保存在这样的一个Delayed类型的延迟任务里并设定20秒的延迟时间。然后交给DelayQueue队列。DelayQueue会调用compareTo方法,根据剩余延迟时间对任务排序。剩余延迟时间越短的越靠近队首,这样就会被优先执行。

3.DelayQueue的用法

首先定义一个Delayed类型的延迟任务类,要能保持任务数据(放在com.tianji.learning.utils)

@Data
public class DelayTask<D> implements Delayed {//任务数据private  D data;//到期时间private Long deadlineNanos;public DelayTask(D data, Duration delayTime){this.data=data;//到期时间等于当前时间加上延迟时间this.deadlineNanos=System.nanoTime()+delayTime.toNanos();}//获取剩余延迟时间@Overridepublic long getDelay(TimeUnit unit) {//unit.convert()将时间转为指定的时间单位, Math.max()不让剩余时间为0,最小为0return unit.convert(Math.max(0,deadlineNanos-System.nanoTime()),TimeUnit.NANOSECONDS);}//比较任务@Overridepublic int compareTo(Delayed o) {//当前任务剩余时间和指定的任务剩余时间进行比较long l=getDelay(TimeUnit.NANOSECONDS)-o.getDelay(TimeUnit.NANOSECONDS);if(l>0){return 1;}else if(l<0){return -1;}else {return 0;}}
}

接下来就可以创建延迟任务,交给延迟队列保存:

@Slf4j
public class DelayTaskTest {@Testvoid testDealayQueue() throws InterruptedException {//1.初始化延迟队列DelayQueue<DelayTask<String>> queue = new DelayQueue<>();//2.向队列中添加延迟执行的任务log.info("开始初始化延迟任务。。。");queue.add(new DelayTask<>("任务3", Duration.ofSeconds(3)));queue.add(new DelayTask<>("任务1", Duration.ofSeconds(1)));queue.add(new DelayTask<>("任务2", Duration.ofSeconds(2)));//3.尝试执行任务while (true) {//取出任务,DelayTask<String> task = queue.take();log.info("执行任务:{}",task.getData()  );}}
}执行结果:
15:50:01.011 [main] INFO DelayTaskTest - 开始初始化延迟任务。。。
15:50:02.025 [main] INFO DelayTaskTest - 执行任务:任务1
15:50:03.033 [main] INFO DelayTaskTest - 执行任务:任务2
15:50:04.021 [main] INFO DelayTaskTest - 执行任务:任务3

注意

这里我们是直接同一个线程来执行任务了。当没有任务的时候线程会被阻塞。而在实际开发中,我们会准备线程池,开启多个线程来执行队列中的任务。

(4)代码改造

1.定义延迟任务工具类

首先,我们要定义一个工具类,帮助我们改造整个业务。在提交学习记录业务中,需要用到异步任务和缓存的地方有以下几处:

因此,我们的工具类就应该具备上述4个方法:

  • ① 添加播放记录到Redis,并添加一个延迟检测任务到DelayQueue

  • ② 查询Redis缓存中的指定小节的播放记录

  • ③ 删除Redis缓存中的指定小节的播放记录

  • ④ 异步执行DelayQueue中的延迟检测任务,检测播放进度是否变化,如果无变化则写入数据库

在com.tianji.learning.utils添加类:

@Slf4j
@Component
@RequiredArgsConstructor
public class LearningRecordDelayTaskHandler {private final static String RECORD_KEY_TEMPLATE = "learning:record:";// volatile解决多线程修改其他线程不可见问题private static volatile boolean begin = true;private final StringRedisTemplate redisTemplate;private final DelayQueue<DelayTask<RecordTaskData>> queue=new DelayQueue<>();private final LearningRecordMapper learningRecordMapper;private final ILearningLessonService lessonService;//当前类被初始化并且上面的bean被初始化之后,会执行本方法@PostConstructpublic void init() {//异步执行,如果不这样运行,while 循环会阻塞,导致无法执行CompletableFuture.runAsync(this::handleDelayTask);}//整个容器销毁之前执行,停止执行延迟任务@PreDestroypublic void destroy() {begin = false;log.debug("延迟任务停止执行");}public void handleDelayTask() {while (begin) {try {//1.获取到期的延迟任务DelayTask<RecordTaskData> task = queue.take();RecordTaskData data = task.getData();//2.查询redis缓存LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId());if (record == null) {continue;}//3. 比较数据,moment值if (!Objects.equals(data.getMoment(), record.getMoment())) {//不一致,说明用户还在持续提交播放进度,放弃旧数据continue;}//4.一致,持久化播放进度数据到数据库//4.1 更新学习记录的momentrecord.setFinished(null);learningRecordMapper.updateById(record);//4.2 更新课表最近学习信息LearningLesson lesson = new LearningLesson();lesson.setId(data.getLessonId());lesson.setLatestSectionId(data.getSectionId());lesson.setLatestLearnTime(LocalDateTime.now());lessonService.updateById(lesson);} catch (Exception e) {log.error("处理延迟任务异常", e);}}}public void addLearningRecordTask(LearningRecord record) {//1.添加数据到redis缓存writeRecordCache(record);//2.提交延迟任务到延迟队列DelayQueuequeue.add(new DelayTask<>(new RecordTaskData(record), Duration.ofSeconds(20)));}//将数据写到redispublic void writeRecordCache(LearningRecord record) {log.debug("更新学习记录的缓存数据");try {//1. 数据转换String json = JsonUtils.toJsonStr(new RecordCacheData(record));//2. 写入redisString key = RECORD_KEY_TEMPLATE + StringUtils.toString(record.getLessonId());redisTemplate.opsForHash().put(key, record.getSectionId().toString(), json);//3. 添加缓存过期时间redisTemplate.expire(key, Duration.ofDays(1));} catch (Exception e) {log.error("更新学习记录缓存异常", e);}}//读取缓存数据public LearningRecord readRecordCache(Long lessonId, Long sectionId) {try {//1.读取Redis数据String key = RECORD_KEY_TEMPLATE +StringUtils.toString(lessonId) ;Object cacheData = redisTemplate.opsForHash().get(key, sectionId.toString());if (cacheData == null) {return null;}//2.数据检查和转换return JsonUtils.toBean(cacheData.toString(), LearningRecord.class);} catch (Exception e) {log.error("从缓存中读取数据异常", e);return null;}}//清理缓存public void clearRecordCache(Long lessonId, Long sectionId) {//删除数据String key = RECORD_KEY_TEMPLATE +StringUtils.toString(lessonId);redisTemplate.opsForHash().delete(key, sectionId.toString());}//放到redis中的数据结构@Data@NoArgsConstructorprivate static class RecordCacheData {private Long id;private Integer moment;private Boolean finished;public RecordCacheData(LearningRecord record) {this.id = record.getId();this.moment = record.getMoment();this.finished = record.getFinished();}}//存入延迟任务的数据@Data@NoArgsConstructorprivate static class RecordTaskData {private Long lessonId;private Long sectionId;private Integer moment;public RecordTaskData(LearningRecord record) {this.lessonId = record.getLessonId();this.sectionId = record.getSectionId();this.moment = record.getMoment();}}
}

2.改造提交学习记录功能

修改LearningRecordServiceImpl:

@Service
@RequiredArgsConstructor
public class LearningRecordServiceImpl extends ServiceImpl<LearningRecordMapper, 
LearningRecord> implements ILearningRecordService {private final ILearningLessonService lessonService;private final CourseClient courseClient;private final LearningRecordDelayTaskHandler taskHandler;@Overridepublic LearningLessonDTO queryLearningRecordByCourse(Long courseId) {//1. 获取登录用户Long userId = UserContext.getUser();//2. 查询课表LearningLesson lesson = lessonService.queryByUserIdAndCourseId(userId, courseId);if (lesson == null) {return null;}//3. 查询学习记录//select * from learning_record where lesson_id = #{}?List<LearningRecord> records = lambdaQuery().eq(LearningRecord::getLessonId, lesson.getId()).list();//封装结果LearningLessonDTO dto = new LearningLessonDTO();dto.setId(lesson.getId());dto.setLatestSectionId(lesson.getLatestSectionId());dto.setRecords(BeanUtils.copyList(records, LearningRecordDTO.class));return dto;}@Override@Transactionalpublic void addLearningRecord(LearningRecordFormDTO recordDto) {//1. 获取登陆用户Long userId = UserContext.getUser();//2. 处理学习记录boolean finished = false;if (recordDto.getSectionType() == SectionType.VIDEO) {//2.1 处理视频finished = handleVideoRecord(userId, recordDto);} else {//2.1 处理考试finished = handleExamRecord(userId, recordDto);}if(!finished){//没有新学完的小节,无需更新课表中的学习进度return;}//3. 处理课表数据handleLearningLessonsChanges(recordDto);}private void handleLearningLessonsChanges(LearningRecordFormDTO recordDto) {//1. 查询课表LearningLesson lesson = lessonService.getById(recordDto.getLessonId());if (lesson==null){throw  new BizIllegalException("课程不存在,无法更新数据!");}//2.判断是否有新的完成小节boolean allLearned=false;//3. 如果有新完成的小节,则需要查询课程数据CourseFullInfoDTO cInfo =courseClient.getCourseInfoById(lesson.getCourseId(), false, false);if (cInfo==null){throw  new BizIllegalException("课程不存在,无法更新数据!");}//4. 比较课程是否全部学完:已学习小节>=课程总小节allLearned = lesson.getLearnedSections() +1>= cInfo.getSectionNum();//更新课表lessonService.lambdaUpdate().set(lesson.getLearnedSections()==0,LearningLesson::getStatus, LessonStatus.LEARNING.getValue()).set(allLearned,LearningLesson::getStatus, LessonStatus.FINISHED.getValue()).setSql("learned_sections = learned_sections+1").eq(LearningLesson::getId, lesson.getId()).update();}private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDto){//1. 查询旧的学习记录,先去redis查询,差不到在查数据库LearningRecord old=queryOldRecord(recordDto.getLessonId(), recordDto.getSectionId());//2.判断是否存在if (old == null) {//3. 不存在,则新增//3.1 转换poLearningRecord learningRecord = BeanUtils.copyBean(recordDto,LearningRecord.class);//3.2. 填充数据learningRecord.setUserId(userId);//3.3 写入数据库boolean success = save(learningRecord);if (!success) {throw new DbException("新增学习记录失败");}return false;}//4.存在,则更新//4.1 判断是否是第一次完成boolean finished=!old.getFinished()&&recordDto.getMoment()*2>=recordDto.getDuration();if(!finished){LearningRecord record=new LearningRecord();record.setLessonId(recordDto.getLessonId());record.setSectionId(recordDto.getSectionId());record.setMoment(recordDto.getMoment());record.setId(old.getId());record.setFinished(old.getFinished());taskHandler.addLearningRecordTask(record);return false;}//4.2 更新数据boolean success= lambdaUpdate().set(LearningRecord::getMoment, recordDto.getMoment()).set( LearningRecord::getFinished, true).set( LearningRecord::getFinishTime, recordDto.getCommitTime()).eq(LearningRecord::getId, old.getId()).update();if(!success){throw new DbException("更新学习记录失败");}//4.3 清理缓存taskHandler.clearRecordCache(recordDto.getLessonId(), recordDto.getSectionId());return true;}//去redis查询旧学习记录private LearningRecord queryOldRecord(Long lessonId, Long sectionId) {//1. 查询缓存LearningRecord record=taskHandler.readRecordCache(lessonId, sectionId);//2.如果命中,直接返回if (record!=null){return record;}//3.未命中,查询数据库record=lambdaQuery().eq(LearningRecord::getSectionId, sectionId).eq(LearningRecord::getLessonId, lessonId).one();//4.写入缓存taskHandler.writeRecordCache(record);return record;}private boolean handleExamRecord(Long userId, LearningRecordFormDTO recordDto){//1. 转换DTO为POLearningRecord learningRecord = BeanUtils.copyBean(recordDto,LearningRecord.class);//2. 填充数据learningRecord.setUserId(userId);learningRecord.setFinished(true);learningRecord.setFinishTime(recordDto.getCommitTime());//3.写入数据库boolean success = save(learningRecord);if (!success) {throw new DbException("新增考试记录失败");}return true;}
}

五.互动问答

(一)分析产品原型

1.分析业务流程

统计接口:

2.设计业务接口

(1)新增互动问题接口

需求:在课程详情页,或者用户学习视频页面,都可以对当前课程提出疑问。

(2)编辑互动问题接口

需求:在课程详情页,用户可以对自己提出的问题做编辑,修改问题的相关信息

(3)用户端分页查询问题

需求:在课程详情页或视频学习页面,都可以分页查询课程相关的问题列表,数据较多采用分页查询

(4)用户端根据id查询问题详情

需求:在课程详情页用户点击某个互动问题后,会进入问答详情页面,查看问题详细信息

(5)管理端分页查询问题

需求:在后台管理页面,教师可以查看学员提问的问题并予以答复,查询采用分页查询

管理端问题VO属性:

(6)管理端分页查询问题

需求:在后台管理页面,教师可以查看学员提问的问题并予以答复,查询采用分页查询

(7)管理端根据id查询问题详情

需求:在后台管理的问答列表中,管理员可以点击并查看某个问题的详细信息

(8)新增回答或评论

需求:在问题详情页面,学员可以回答问题,或者对他人的回答做评论

(9)分页查询回答和评论列表

需求:在后台管理页面,教师可以查看学员问题详情下的回答列表或者回答下的评论列表

回答和评论的VO属性:

3.设计数据库

ER图:

问题表:

CREATE TABLE IF NOT EXISTS `interaction_question` (`id` bigint NOT NULL COMMENT '主键,互动问题的id',`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '互动问题的标题',`description` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '问题描述信息',`course_id` bigint NOT NULL COMMENT '所属课程id',`chapter_id` bigint NOT NULL COMMENT '所属课程章id',`section_id` bigint NOT NULL COMMENT '所属课程节id',`user_id` bigint NOT NULL COMMENT '提问学员id',`latest_answer_id` bigint DEFAULT NULL COMMENT '最新的一个回答的id',`answer_times` int unsigned NOT NULL DEFAULT '0' COMMENT '问题下的回答数量',`anonymity` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否匿名,默认false',`hidden` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否被隐藏,默认false',`status` tinyint DEFAULT '0' COMMENT '管理端问题状态:0-未查看,1-已查看',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '提问时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`) USING BTREE,KEY `idx_course_id` (`course_id`) USING BTREE,KEY `section_id` (`section_id`),KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='互动提问的问题表';

评论表:

CREATE TABLE IF NOT EXISTS `interaction_reply` (`id` bigint NOT NULL COMMENT '互动问题的回答id',`question_id` bigint NOT NULL COMMENT '互动问题问题id',`answer_id` bigint DEFAULT '0' COMMENT '回复的上级回答id',`user_id` bigint NOT NULL COMMENT '回答者id',`content` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '回答内容',`target_user_id` bigint DEFAULT '0' COMMENT '回复的目标用户id',`target_reply_id` bigint DEFAULT '0' COMMENT '回复的目标回复id',`reply_times` int NOT NULL DEFAULT '0' COMMENT '评论数量',`liked_times` int NOT NULL DEFAULT '0' COMMENT '点赞数量',`hidden` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否被隐藏,默认false',`anonymity` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否匿名,默认false',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`) USING BTREE,KEY `idx_question_id` (`question_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='互动问题的回答或评论';

4.代码生成

创建新的功能分支。这里是互动问答功能,在dev分支基础上创建一个问答分支:feature-qa

注意把controller路径修改为Restful风格:

问题和评论的id都采用雪花算法:

问题存在已查看和未查看两种状态,我们定义出一个枚举来标示:

@Getter
public enum QuestionStatus implements BaseEnum {UN_CHECK(0, "未查看"),CHECKED(1, "已查看"),;@JsonValue@EnumValueint value;String desc;QuestionStatus(int value, String desc) {this.value = value;this.desc = desc;}@JsonCreator(mode = JsonCreator.Mode.DELEGATING)public static QuestionStatus of(Integer value){if (value == null) {return null;}for (QuestionStatus status : values()) {if (status.equalsValue(value)) {return status;}}return null;}
}

然后把InteractionQuestion类中的状态修改为枚举类型:

(二)问题相关接口

1.新增互动问题

实体类:

@Data
@ApiModel(description = "互动问题表单信息")
public class QuestionFormDTO {@ApiModelProperty("课程id")@NotNull(message = "课程id不能为空")private Long courseId;@ApiModelProperty("章id")@NotNull(message = "章id不能为空")private Long chapterId;@ApiModelProperty("小节id")@NotNull(message = "小节id不能为空")private Long sectionId;@ApiModelProperty("标题")@NotNull(message = "标题不能为空")@Length(min = 1, max = 254, message = "标题长度太长")private String title;@ApiModelProperty("互动问题描述")@NotNull(message = "问题描述不能为空")private String description;@ApiModelProperty("是否匿名提问")private Boolean anonymity;
}

controller:

@RestController
@RequestMapping("/questions")
@Api(tags = "互动问答的相关接口")
@RequiredArgsConstructor
public class InteractionQuestionController {private final IInteractionQuestionService questionService;@ApiOperation("新增互动问题")@PostMapping()public void saveQuestion(@RequestBody @Valid QuestionFormDTO questionDTO){questionService.saveQuestion(questionDTO);}}

service:

public interface IInteractionQuestionService extends 
IService<InteractionQuestion> {void saveQuestion(QuestionFormDTO questionDTO);
}@Service
public class InteractionQuestionServiceImpl extends ServiceImpl
<InteractionQuestionMapper, InteractionQuestion> implements 
IInteractionQuestionService {@Overridepublic void saveQuestion(QuestionFormDTO questionDTO) {//1. 获取当前登录的用户idLong userId= UserContext.getUser();//2. 数据封装InteractionQuestion question = BeanUtils.copyBean(questionDTO, InteractionQuestion.class);question.setId(userId);//3.写入数据库save(question);}
}

2. 用户端分页查询问题

请求实体:

EqualsAndHashCode(callSuper = true)
@Data
@ApiModel(description = "互动问题分页查询条件")
public class QuestionPageQuery extends PageQuery {// 用户端查询条件@ApiModelProperty(value = "课程id")private Long courseId;@ApiModelProperty(value = "小节id", example = "1")private Long sectionId;@ApiModelProperty(value = "是否只查询我的问题", example = "1")private Boolean onlyMine;
}

返回实体:

@Data
@ApiModel(description = "用户端互动问题信息")
public class QuestionVO {@ApiModelProperty("主键id")private Long id;@ApiModelProperty("互动问题名称")private String title;@ApiModelProperty("回答数量,0表示没有回答")private Integer answerTimes;@ApiModelProperty(value = "创建时间", example = "2022-7-18 19:52:36")private LocalDateTime createTime;@ApiModelProperty("是否匿名提问")private Boolean anonymity;@ApiModelProperty("提问者id")private Long userId;@ApiModelProperty("提问者昵称")private String userName;@ApiModelProperty("提问者头像")private String userIcon;@ApiModelProperty("最新的回答信息")private String latestReplyContent;@ApiModelProperty("最新的回答者昵称")private String latestReplyUser;
}

controller:

@ApiModelProperty("查询互动问题")
@GetMapping("page")
public PageDTO<QuestionVO> questQuestionPage(QuestionPageQuery query){return questionService.queryQuestionPage(query);
}

service:

PageDTO<QuestionVO> queryQuestionPage(QuestionPageQuery query);private final IInteractionReplyService replyService;
private final UserClient userClient;
@Override
public PageDTO<QuestionVO> queryQuestionPage(QuestionPageQuery query) {//1.参数校验,课程id和小节id不能为空Long courseId=query.getCourseId();Long sectionId=query.getSectionId();if(courseId==null&&sectionId==null){throw  new BadRequestException("课程id和小节id不能都为空");}//分页查询Page<InteractionQuestion> page = lambdaQuery()//不要description这个字段.select(InteractionQuestion.class,info->!info.getProperty().equals("description")).eq(query.getOnlyMine(), InteractionQuestion::getUserId,UserContext.getUser()).eq(courseId != null, InteractionQuestion::getCourseId, courseId).eq(sectionId != null, InteractionQuestion::getSectionId, sectionId).eq(InteractionQuestion::getHidden, false).page(query.toMpPageDefaultSortByCreateTimeDesc());List<InteractionQuestion> records = page.getRecords();if(CollUtils.isEmpty(records)){//如果是空返回空return PageDTO.empty(page);}//3. 根据id查询提问者和最近一次回答的信息Set<Long> userIds=new HashSet<>();Set<Long> answerIds=new HashSet<>();//3.1 得到问题当中的提问者id和最近一次回答的idfor (InteractionQuestion record : records){//只查询非匿名的问题if(!record.getAnonymity()){userIds.add(record.getUserId());}answerIds.add(record.getLatestAnswerId());}//3.2 根据id查询最近一次回答answerIds.remove(null);Map<Long, InteractionReply> replyMap=new HashMap<>(answerIds.size());if (CollUtils.isNotEmpty(answerIds)){List<InteractionReply> replies = replyService.listByIds(answerIds);for (InteractionReply reply : replies){replyMap.put(reply.getId(), reply);if(!reply.getAnonymity()){userIds.add(reply.getUserId());}}}//3.3 根据id查询用户信息(提问者)userIds.remove(null);Map<Long, UserDTO> userMap = new HashMap<>(userIds.size());if(CollUtils.isNotEmpty(userIds)){List<UserDTO> users = userClient.queryUserByIds(userIds);userMap= users.stream().collect(Collectors.toMap(UserDTO::getId, u -> u));}//4.封装VOList<QuestionVO> voList=new ArrayList<>(records.size());for (InteractionQuestion record : records){//4.1 将PO转为VOQuestionVO vo = BeanUtils.copyBean(record, QuestionVO.class);voList.add( vo);//4.2 封装提问者信息if(!record.getAnonymity()){UserDTO user = userMap.get(record.getUserId());if(user!=null){vo.setUserName(user.getName());vo.setUserIcon(user.getIcon());}}//4.3 封装最近一次回答信息InteractionReply reply = replyMap.get(record.getLatestAnswerId());if(reply!=null){vo.setLatestReplyContent(reply.getContent());if (!reply.getAnonymity()){UserDTO user = userMap.get(reply.getUserId());vo.setLatestReplyUser(user.getName());}}}return PageDTO.of(page, voList);
}

3. 根据id查询问题详情

给 QuestionVO添加一个字段description:

@Data
@ApiModel(description = "用户端互动问题信息")
public class QuestionVO {@ApiModelProperty("主键id")private Long id;@ApiModelProperty("互动问题名称")private String title;@ApiModelProperty("互动问题描述")private String description;@ApiModelProperty("回答数量,0表示没有回答")private Integer answerTimes;@ApiModelProperty(value = "创建时间", example = "2022-7-18 19:52:36")private LocalDateTime createTime;@ApiModelProperty("是否匿名提问")private Boolean anonymity;@ApiModelProperty("提问者id")private Long userId;@ApiModelProperty("提问者昵称")private String userName;@ApiModelProperty("提问者头像")private String userIcon;@ApiModelProperty("最新的回答信息")private String latestReplyContent;@ApiModelProperty("最新的回答者昵称")private String latestReplyUser;
}

controller:

@ApiOperation("根据id查询互动问题详情")
public QuestionVO queryQuestionById(@PathVariable("id") Long id){return questionService.queryQuestionById( id);
}

service:

QuestionVO queryQuestionById(Long id);@Override
public QuestionVO queryQuestionById(Long id) {//1. 根据id查询数据InteractionQuestion question = getById(id);//2. 数据校验if(question==null||question.getHidden()){//没有数据或者被隐藏了return null;}//3. 查询提问者信息UserDTO user=null;if(!question.getAnonymity()){user = userClient.queryUserById(question.getUserId());}//4. 封装VOQuestionVO vo = BeanUtils.copyBean(question, QuestionVO.class);if(user!=null){vo.setUserName(user.getName());vo.setUserIcon(user.getIcon());}return vo;
}

4.管理端分页查询问题

返回VO:

@Data
@ApiModel(description = "用户端互动问题信息")
public class QuestionAdminVO {@ApiModelProperty("主键id")private Long id;@ApiModelProperty("互动问题名称")private String title;@ApiModelProperty("互动问题描述")private String description;@ApiModelProperty("回答数量,0表示没有回答")private Integer answerTimes;@ApiModelProperty(value = "创建时间", example = "2022-7-18 19:52:36")private LocalDateTime createTime;@ApiModelProperty("管理端问题状态:0-未查看,1-已查看")private Integer status;@ApiModelProperty("是否被隐藏")private Boolean hidden;@ApiModelProperty("提问者昵称")private String userName;@ApiModelProperty("课程名称")private String courseName;@ApiModelProperty("章名称")private String chapterName;@ApiModelProperty("节名称")private String sectionName;@ApiModelProperty("三级分类名称,中间使用/隔开")private String categoryName;
}

分页查询条件query:

@EqualsAndHashCode(callSuper = true)
@Data
@ApiModel(description = "互动问题管理端分页查询条件")
public class QuestionAdminPageQuery extends PageQuery {@ApiModelProperty(value = "课程名称搜索关键字", example = "Redis")private String courseName;@ApiModelProperty(value = "管理端问题状态:0-未查看,1-已查看", example = "1")private Integer status;@ApiModelProperty(value = "更新时间区间的开始时间", example = "2022-7-18 19:52:36")@DateTimeFormat(pattern = DateUtils.DEFAULT_DATE_TIME_FORMAT)private LocalDateTime beginTime;@DateTimeFormat(pattern = DateUtils.DEFAULT_DATE_TIME_FORMAT)@ApiModelProperty(value = "更新时间区间的结束时间", example = "2022-7-18 19:52:36")private LocalDateTime endTime;
}

新建controller:

@RestController
@RequestMapping("/admin/questions")
@Api(tags = "互动问答的相关接口")
@RequiredArgsConstructor
public class InteractionQuestionAdminController {private final IInteractionQuestionService questionService;@ApiModelProperty("管理端分页查询互动问题")@GetMapping("page")public PageDTO<QuestionAdminVO> questQuestionPageAdmin(QuestionAdminPageQuery query){return questionService.questQuestionPageAdmin(query);}
}

service:

PageDTO<QuestionAdminVO> questQuestionPageAdmin(QuestionAdminPageQuery query);private final CourseClient courseClient;
private final SearchClient searchClient;
private final CatalogueClient catalogueClient;
private final CategoryCache categoryCache;@Override
public PageDTO<QuestionAdminVO> questQuestionPageAdmin
(QuestionAdminPageQuery query) {//1. 处理课程名称,得到课程idList<Long> courseIds = null;if (StringUtils.isNotBlank(query.getCourseName())) {courseIds = searchClient.queryCoursesIdByName(query.getCourseName());if (CollUtils.isEmpty(courseIds)) {return PageDTO.empty(0L, 0L);}}//2.分页查询Integer status = query.getStatus();LocalDateTime begin = query.getBeginTime();LocalDateTime end = query.getEndTime();Page<InteractionQuestion> page = lambdaQuery().in(courseIds != null, InteractionQuestion::getCourseId, courseIds).eq(status != null, InteractionQuestion::getStatus, status).gt(begin != null, InteractionQuestion::getCreateTime, begin).lt(end != null, InteractionQuestion::getCreateTime, end).page(query.toMpPageDefaultSortByCreateTimeDesc());List<InteractionQuestion> records = page.getRecords();if (CollUtils.isEmpty(records)) {return PageDTO.empty(page);}//3. 准备VO需要的数据,用户数据,课程数据,章节数据Set<Long> userIds = new HashSet<>();Set<Long> cIds = new HashSet<>();Set<Long> chtaIds = new HashSet<>();//3.1 获取各种数据的id集合for (InteractionQuestion q : records){userIds.add(q.getUserId());cIds.add(q.getCourseId());chtaIds.add(q.getChapterId());chtaIds.add(q.getSectionId());}//3.2 根据id查询用户List<UserDTO> users = userClient.queryUserByIds(userIds);Map<Long, UserDTO> userMap = new HashMap<>(users.size());if(CollUtils.isNotEmpty(users)){userMap=users.stream().collect(Collectors.toMap(UserDTO::getId, u->u));}//3.3 根据id查询课程List<CourseSimpleInfoDTO> cInfos=courseClient.getSimpleInfoList(cIds);Map<Long, CourseSimpleInfoDTO> cInfoMap = new HashMap<>(cInfos.size());if(CollUtils.isNotEmpty(cInfos)){cInfoMap=cInfos.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c->c));}//3.4 根据id查询章节List<CataSimpleInfoDTO> catas=catalogueClient.batchQueryCatalogue(chtaIds);Map<Long, String> cataMap = new HashMap<>(catas.size());if(CollUtils.isNotEmpty(catas)){cataMap=catas.stream().collect(Collectors.toMap(CataSimpleInfoDTO::getId,CataSimpleInfoDTO::getName));}//4.封装voList<QuestionAdminVO> voList = new ArrayList<>(records.size());for (InteractionQuestion q : records){//4.1 将po转vo,属性拷贝QuestionAdminVO vo=BeanUtils.copyBean(q, QuestionAdminVO.class);//4.2 用户信息UserDTO user = userMap.get(q.getUserId());if(user!=null){vo.setUserName(user.getName());}//4.3 课程信息及其分类信息CourseSimpleInfoDTO cInfo = cInfoMap.get(q.getCourseId());if(cInfo!=null){vo.setCourseName(cInfo.getName());vo.setCategoryName(categoryCache.getCategoryNames(cInfo.getCategoryIds()));}//4.4 章节信息vo.setCategoryName(cataMap.getOrDefault(q.getChapterId(),""));vo.setSectionName(cataMap.getOrDefault(q.getSectionId(),""));voList.add(vo);}return PageDTO.of(page, voList);
}

5. 修改互动问题(练习)

controller:

@ApiOperation("修改互动问题")
@PutMapping("/{id}")
public void updateQuestion(@PathVariable("id") Long id,
@RequestBody @Valid QuestionFormDTO questionDTO){questionService.updateQuestion(id,questionDTO);
}

service:

 void updateQuestion(Long id, QuestionFormDTO questionDTO);@Override
public void updateQuestion(Long id, QuestionFormDTO questionDTO) {//1.数据封装InteractionQuestion question = BeanUtils.copyBean(questionDTO, InteractionQuestion.class);//2.设置idquestion.setId(id);//3.更新数据updateById(question);
}

6.删除我的问题(练习)

controller:

@ApiOperation("删除问题")
@DeleteMapping("/{id}")
public void deleteQuestion(@PathVariable("id") Long id){questionService.deleteQuestion(id);
}

service:

void deleteQuestion(Long id);@Override
@Transactional
public void deleteQuestion(Long id) {//1.查询问题是否存在InteractionQuestion question = getById(id);if (question == null) {throw new BadRequestException("问题不存在");}//2.判断是否是当前用户提问的Long userId = UserContext.getUser();if (!question.getUserId().equals(userId)){throw new BadRequestException("只能删除自己提问的问题");}//3.删除问题removeById(id);//4.删除问题下的回答及评论LambdaQueryWrapper<InteractionReply> replyQuery = newLambdaQueryWrapper<InteractionReply>().eq(InteractionReply::getQuestionId, id);replyService.remove(replyQuery);
}

7.管理端隐藏或显示问题(练习)

controller:

@ApiModelProperty("管理端显示隐藏问题")
@PutMapping("/{id}/hidden/{hidden}")
public void updateQuestionHidden(@PathVariable("id") Long id,
@PathVariable("hidden") Boolean hidden){questionService.updateQuestionHidden(id,hidden);
}

service:

void updateQuestionHidden(Long id, Boolean hidden);@Override
public void updateQuestionHidden(Long id, Boolean hidden) {//1.根据id查询问题InteractionQuestion question = getById(id);if (question == null) {throw new BadRequestException("问题不存在");}//2.设置隐藏状态question.setHidden(hidden);//3.更新数据updateById(question);
}

六.点赞系统

(一)需求分析

1.业务需求

点赞功能与其它功能不同,没有复杂的原型和需求,仅仅是一个点赞、取消点赞的操作。所以,今天我们就不需要从原型图来分析,而是仅仅从这个功能的实现方案来思考。

首先我们来分析整理一下点赞业务的需求,一个通用点赞系统需要满足下列特性:

  • 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能

  • 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。

  • 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发

  • 安全:要做好并发安全控制,避免重复点赞

2.实现思路

要保证安全,避免重复点赞,我们就必须保存每一次点赞记录。只有这样在下次用户点赞时我们才能查询数据,判断是否是重复点赞。同时,因为业务方经常需要根据点赞数量排序,因此每个业务的点赞数量也需要记录下来。

综上,点赞的基本思路如下:

但问题来了,我们说过点赞服务必须独立,因此必须抽取为一个独立服务。多个其它微服务业务的点赞数据都有点赞系统来维护。但是问题来了:如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段。但是点赞系统无法修改其它业务服务的数据库,否则就出现了业务耦合。该怎么办呢?

点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。

于是,实现思路变成了这样:

(二)数据结构

点赞的数据结构分两部分,一是点赞记录,二是与业务关联的点赞数

点赞数自然是与具体业务表关联在一起记录,比如互动问答的点赞,自然是在问答表中记录点赞数。学员笔记点赞,自然是在笔记表中记录点赞数。

在之前实现互动问答的时候,我们已经给回答表设计了点赞数字段了:

其它业务也是类似的。

因此,本节只需要实现点赞记录的表结构设计即可。

1.ER图

点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:

  • 点赞目标id

  • 点赞人id

不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:

  • 点赞对象类型(为了通用性)

当然还有点赞时间,综上对应的数据库ER图如下:

2.表结构

由于点赞系统是独立于其它业务的,这里我们需要创建一个新的数据库:tj_remark

CREATE DATABASE tj_remark CHARACTER SET 'utf8mb4';

然后在ER图基础上,加上一些通用属性,点赞记录表结构如下:

CREATE TABLE IF NOT EXISTS `liked_record` (`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',`user_id` bigint NOT NULL COMMENT '用户id',`biz_id` bigint NOT NULL COMMENT '点赞的业务id',`biz_type` VARCHAR(16) NOT NULL COMMENT '点赞的业务类型',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`),UNIQUE KEY `idx_biz_user` (`biz_id`,`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞记录表';

3.创建微服务

由于点赞系统是一个独立微服务,我们需要创建一个新的微服务模块(tr-remark)。

在pom.xml中填入依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><parent><artifactId>tjxt</artifactId><groupId>com.tianji</groupId><version>1.0.0</version></parent><modelVersion>4.0.0</modelVersion><artifactId>tj-remark</artifactId><properties><maven.compiler.source>11</maven.compiler.source><maven.compiler.target>11</maven.compiler.target></properties><dependencies><!--auth-sdk--><dependency><groupId>com.tianji</groupId><artifactId>tj-auth-resource-sdk</artifactId><version>1.0.0</version></dependency><!--api--><dependency><groupId>com.tianji</groupId><artifactId>tj-api</artifactId><version>1.0.0</version></dependency><!--web--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--mybatis--><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!--Redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!--discovery--><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency><!--config--><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency><!--mq--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></dependency><!--loadbalancer--><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency></dependencies><build><finalName>${project.artifactId}</finalName><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><executions><execution><goals><goal>build-info</goal></goals></execution></executions><configuration><mainClass>com.tianji.remark.RemarkApplication</mainClass></configuration></plugin></plugins></build>
</project>

然后是配置文件:bootstrap.yml

server:port: 8091  #端口tomcat:uri-encoding: UTF-8   #服务编码
spring:profiles:active: devapplication:name: remark-servicecloud:nacos:config:file-extension: yamlshared-configs: # 共享配置- data-id: shared-spring.yaml # 共享spring配置refresh: false- data-id: shared-redis.yaml # 共享redis配置refresh: false- data-id: shared-mybatis.yaml # 共享mybatis配置refresh: false- data-id: shared-logs.yaml # 共享日志配置refresh: false- data-id: shared-feign.yaml # 共享feign配置refresh: false- data-id: shared-mq.yaml # 共享mq配置refresh: false
tj:swagger:enable: trueenableResponseWrap: truepackage-path: com.tianji.remark.controllertitle: 天机学堂 - 评价中心接口文档description: 该服务包含评价、点赞等功能contact-name: 传智教育·研究院contact-url: http://www.itcast.cn/contact-email: zhanghuyi@itcast.cnversion: v1.0jdbc:database: tj_remarkauth:resource:enable: true # 登录拦截功能

接着是bootstrap-dev.yml:

spring:cloud:nacos:server-addr: 192.168.150.101:8848 # nacos注册中心discovery:namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0group: DEFAULT_GROUPip: 192.168.150.101
logging:level:com.tianji: debug

然后是bootstrap-local.yml:

spring:cloud:nacos:server-addr: 192.168.150.101:8848 # nacos注册中心discovery:namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0group: DEFAULT_GROUPip: 192.168.150.1
logging:level:com.tianji: debug

最后,新建一个启动类:

package com.tianji.remark;import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;import java.net.InetAddress;
import java.net.UnknownHostException;@Slf4j
@EnableScheduling
@SpringBootApplication
@MapperScan("com.tianji.remark.mapper")
public class RemarkApplication {public static void main(String[] args) throws UnknownHostException {SpringApplication app = new SpringApplicationBuilder(RemarkApplication.class).build(args);Environment env = app.run(args).getEnvironment();String protocol = "http";if (env.getProperty("server.ssl.key-store") != null) {protocol = "https";}log.info("--/\n---------------------------------------------------------------------------------------\n\t" +"Application '{}' is running! Access URLs:\n\t" +"Local: \t\t{}://localhost:{}\n\t" +"External: \t{}://{}:{}\n\t" +"Profile(s): \t{}" +"\n---------------------------------------------------------------------------------------",env.getProperty("spring.application.name"),protocol,env.getProperty("server.port"),protocol,InetAddress.getLocalHost().getHostAddress(),env.getProperty("server.port"),env.getActiveProfiles());}
}

项目结构:

微服务搭建后,一定不要忘了在网关配置服务路由,找到tj-gateway服务的bootstrap.yml文件,添加以下内容:


# 。。。其它略
spring:# 。。。其它略cloud:# 。。。其它略gateway:routes:- id: rsuri: lb://remark-servicepredicates:- Path=/rs/**# 。。。其它略default-filters:- StripPrefix=1
# 。。。其它略

为了方便本地启动测试,最后给remark-service添加一个SpringBoot启动项:

4.代码生成

填写数据库信息:

然后生成代码:

(三)实现点赞功能

需求:用户可以给喜欢的回答、笔记等点赞,也可以取消点赞

需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可

在RabbitMQ中,利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey:

当然,真实的RoutingKey不一定如图中所示,这里只是做一个示意。

其实在tj-common中,我们已经定义了MQ的常量:

并且定义了点赞有关的ExchangeRoutingKey常量:

其中的RoutingKey只是一个模板,其中{}部分是占位符,不同业务类型就填写不同的具体值。

我们需要定义一个MQ通知的消息体,由于这个消息体会在各个相关微服务中使用,需要定义到公用的模块中,这里我们定义到tj-api模块:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LikedTimesDTO {/*** 点赞的业务id*/private Long bizId;/*** 总的点赞次数*/private Integer likedTimes;
}

请求参数DTO实体类:

@Data
@ApiModel(description = "点赞记录表单实体")
public class LikeRecordFormDTO {@ApiModelProperty("点赞业务id")@NotNull(message = "业务id不能为空")private Long bizId;@ApiModelProperty("点赞业务类型")@NotNull(message = "业务类型不能为空")private String bizType;@ApiModelProperty("是否点赞,true:点赞;false:取消点赞")@NotNull(message = "是否点赞不能为空")private Boolean liked;
}

代码实现:

首先是tj-remarkcom.tianji.remark.controller.LikedRecordController

@RestController
@RequiredArgsConstructor
@RequestMapping("/likes")
@Api(tags = "点赞记业务相关接口")
public class LikedRecordController {private  final ILikedRecordService likedRecordService;@PostMappingpublic void addLikeRecord(@Valid @RequestBody LikeRecordFormDTO  recordDto){likedRecordService.addLikeRecord(recordDto);}
}

然后是tj-remarkcom.tianji.remark.service.ILikedRecordService

public interface ILikedRecordService extends IService<LikedRecord> {void addLikeRecord(LikeRecordFormDTO recordDto);
}

最后是tj-remark的实现类com.tianji.remark.service.impl.LikedRecordServiceImpl

@Service
@RequiredArgsConstructor
public class LikedRecordServiceImpl extends ServiceImpl<LikedRecordMapper,
LikedRecord> implements ILikedRecordService {private final RabbitMqHelper rabbitMqHelper;@Overridepublic void addLikeRecord(LikeRecordFormDTO recordDto) {//1. 基于前端的参数,判断是执行点赞还是取消点赞boolean success = recordDto.getLiked() ? like(recordDto) : unlike(recordDto);//2.判断是否执行成功,如果失败,则直接结束if (!success) {return;}//3. 如果执行成功,统计点赞总数Integer likeTimes = lambdaQuery().eq(LikedRecord::getBizId, recordDto.getBizId()).count();//4. 发送MQ通知rabbitMqHelper.send(LIKE_RECORD_EXCHANGE,StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, recordDto.getBizType()),LikeTimesDto.of(recordDto.getBizId(), likeTimes));}private boolean unlike(LikeRecordFormDTO recordDto) {return remove(lambdaQuery().eq(LikedRecord::getUserId, UserContext.getUser()).eq(LikedRecord::getBizId, recordDto.getBizId()));}private boolean like(LikeRecordFormDTO recordDto) {Long userId = UserContext.getUser();//1. 查询点赞记录Integer count = lambdaQuery().eq(LikedRecord::getUserId, userId).eq(LikedRecord::getBizId, recordDto.getBizId()).count();//2. 判断是否存在,如果存在,直接结束if (count > 0) {return false;}//3.如果不存在,直接新增LikedRecord record = new LikedRecord();record.setUserId(userId);record.setBizId(recordDto.getBizId());record.setBizType(recordDto.getBizType());save(record);return true;}
}

(四)批量查询点赞状态

业务微服务有查询点赞状态的需求,要求:

   ①编写根据业务id查询当前用户是否点赞的接口

   ②提供对应的FeignClient

   ③改造之前的互动问答分页查询回答接口,把用户是否点赞一并返回

controller:

@GetMapping("list")
@ApiOperation("查询指定业务id的点赞状态")
public Set<Long> isBizLiked(@RequestParam("bizIds")List<Long> bizIds){return likedRecordService.isBizLiked(bizIds);
}

service:

Set<Long> isBizLiked(List<Long> bizIds);@Override
public Set<Long> isBizLiked(List<Long> bizIds) {//1. 获取登录用户idLong userId = UserContext.getUser();//2. 查询点赞状态List<LikedRecord> list=lambdaQuery().eq(LikedRecord::getUserId, userId).in(LikedRecord::getBizId, bizIds).list();//3. 返回结果return list.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
}

暴露Feign接口:

由于该接口是给其它微服务调用的,所以必须暴露出Feign客户端,并且定义好fallback降级处理:

在tj-api模块中定义一个客户端:

其中RemarkClient如下:

@FeignClient(value = "remark-service",fallbackFactory = RemarkClientFallback.class)
public interface RemarkClient {@GetMapping("/likes/list")Set<Long> isBizLiked(@RequestParam("bizIds") Iterable<Long> bizIds);
}

对应的fallback逻辑:

@Slf4j
public class RemarkClientFallback implements FallbackFactory<RemarkClient> {@Overridepublic RemarkClient create(Throwable cause) {log.error("查询remark-service服务异常", cause);return new RemarkClient() {@Overridepublic Set<Long> isBizLiked(Iterable<Long> bizIds) {//返回空集合return Collections.emptySet();}};}
}

由于RemarkClientFallback是定义在tj-apicom.tianji.api包,由于每个微服务扫描包不一致。因此其它引用tj-api的微服务是无法通过扫描包加载到这个类的。

我们需要通过SpringBoot的自动加载机制来加载这些fallback类:

由于SpringBoot会在启动时读取/META-INF/spring.factories文件,我们只需要在该文件中指定了要加载

FallbackConig类:

@Configuration
public class FallbackConfig {@Beanpublic LearningClientFallback learningClientFallback(){return new LearningClientFallback();}@Beanpublic TradeClientFallback tradeClientFallback(){return new TradeClientFallback();}@Beanpublic UserClientFallback userClientFallback(){return new UserClientFallback();}@Beanpublic RemarkClientFallback remarkClientFallback(){return new RemarkClientFallback();}
}

这样所有在其中定义的fallback类都会被加载了。

(五)监听点赞变更的消息 

需求:在学习服务添加MQ监听器,监听点赞数变更的消息,更新回答的点赞数量

tj-learning服务中定义MQ监听器:

@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {private final IInteractionReplyService replyService;@RabbitListener(bindings = @QueueBinding(value = @Queue(value = "qa.liked.times.queue", durable = "true"),exchange = @Exchange(name = MqConstants.Exchange.LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),key = QA_LIKED_TIMES_KEY))public void listenReplyLikedTimesChange(LikeTimesDto likeTimesDto) {log.debug("监听到回答或评论的点赞数变更的消息:{},点赞数:{}", likeTimesDto.getBizId(), likeTimesDto.getLikeTimes());InteractionReply r = new InteractionReply();r.setId(likeTimesDto.getBizId());r.setLikedTimes(likeTimesDto.getLikeTimes());replyService.updateById(r);}
}

(六)点赞功能改进

虽然我们初步实现了点赞功能,不过有一个非常严重的问题,点赞业务包含多次数据库读写操作:

更重要的是,点赞操作波动较大,有可能会在短时间内访问量激增。例如有人非常频繁的点赞、取消点赞。这样就会给数据库带来非常大的压力。

点赞功能可以使用合并写方案:

合并写请求有两个关键点要考虑:

  • 数据如何缓存

  • 缓存何时写入数据库

1.点赞数据缓存

点赞记录中最两个关键信息:

  • 用户是否点赞

  • 某业务的点赞总次数

这两个信息需要分别记录,也就是说我们需要在Redis中设计两种数据结构分别存储。

(1)用户是否点赞

要知道某个用户是否点赞某个业务,就必须记录业务id以及给业务点赞的所有用户id . 由于一个业务可以被很多用户点赞,显然是需要一个集合来记录。而Redis中的集合类型包含四种:

  • List

  • Set

  • SortedSet

  • Hash

而要判断用户是否点赞,就是判断存在且唯一。显然,Set集合是最合适的。我们可以用业务id为Key,创建Set集合,将点赞的所有用户保存其中,格式如下:

KEY(bizId)

VALUE(userId)

bizId:1

userId:1

userId:2

userId:3

可以使用Set集合的下列命令完成点赞功能:

# 判断用户是否点赞
SISMEMBER bizId userId
# 点赞,如果返回1则代表点赞成功,返回0则代表点赞失败
SADD bizId userId
# 取消点赞,就是删除一个元素
SREM bizId userId
# 统计点赞总数
SCARD bizId

由于Redis本身具备持久化机制,AOF提供的数据可靠性已经能够满足点赞业务的安全需求,因此我们完全可以用Redis存储来代替数据库的点赞记录。

也就是说,用户的一切点赞行为,以及将来查询点赞状态我们可以都走Redis,不再使用数据库查询。

(2)点赞次数

由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。

由于需要记录业务id、业务类型、点赞数三个信息:

  • 一个业务类型下包含多个业务id

  • 每个业务id对应一个点赞数。

因此,我们可以把每一个业务类型作为一组,使用Redis的一个key,然后业务id作为键,点赞数作为值。这样的键值对集合,有两种结构都可以满足:

  • Hash:传统键值对集合,无序

  • SortedSet:基于Hash结构,并且增加了跳表。因此可排序,但更占用内存

如果是从节省内存角度来考虑,Hash结构无疑是最佳的选择;但是考虑到将来我们要从Redis读取点赞数,然后移除(避免重复处理)。为了保证线程安全,查询、移除操作必须具备原子性。而SortedSet则提供了几个移除并获取的功能,天生具备原子性。并且我们每隔一段时间就会将数据从Redis移除,并不会占用太多内存。因此,这里我们计划使用SortedSet结构。

格式如下:

KEY(bizType)

Member(bizId)

Score(likedTimes)

likes:qa

bizId:1001

10

bizId:1002

5

likes:note

bizId:2001

9

bizId:2002

21

当用户对某个业务点赞时,我们统计点赞总数,并将其缓存在Redis中。这样一来在一段时间内,不管有多少用户对该业务点赞(热点业务数据,比如某个微博大V),都只在Redis中修改点赞总数,无需修改数据库。

2.点赞数据入库

点赞数据写入缓存了,但是这里有一个新的问题:

何时把缓存的点赞数,通过MQ通知到业务方,持久化到业务方的数据库呢?

在之前的提交播放记录业务中,由于播放记录是定期每隔15秒发送一次请求,频率固定。因此我们可以通过接收到播放记录后延迟20秒检测数据变更来确定是否有新数据到达。

但是点赞则不然,用户何时点赞、点赞频率如何完全不确定。因此无法采用延迟检测这样的手段。怎么办?

事实上这也是大多数合并写请求业务面临的问题,而多数情况下,我们只能通过定时任务,定期将缓存的数据持久化到数据库中。

3.流程图

4. 改造点赞逻辑

基于Redis缓存改造点赞功能:

   ①改造点赞接口

   ②改造点赞状态查询  接口

   ③添加定时任务,实现定时数据同步功能

由于需要访问Redis,我们提前定义一个常量类,把Redis相关的Key定义为常量:

public interface RedisConstants {/*给业务点赞的用户集合的KEY前缀,后缀是业务id*/String LIKE_BIZ_KEY_PREFIX = "likes:set:biz:";/*业务点赞数统计的KEY前缀,后缀是业务类型*/String LIKES_TIMES_KEY_PREFIX = "likes:times:type:";
}
(1)点赞接口

定义一个新的点赞业务实现类,并将LikedRecordServiceImpl注释:

@Service
@RequiredArgsConstructor
public class LikedRecordServiceRedisImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {private final RabbitMqHelper rabbitMqHelper;private final StringRedisTemplate redisTemplate;@Overridepublic void addLikeRecord(LikeRecordFormDTO recordDto) {//1. 基于前端的参数,判断是执行点赞还是取消点赞boolean success = recordDto.getLiked() ? like(recordDto) : unlike(recordDto);//2.判断是否执行成功,如果失败,则直接结束if (!success) {return;}//3. 如果执行成功,统计点赞总数Long likedTimes = redisTemplate.opsForSet().size(RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDto.getBizId());if (likedTimes == null) {return;}//4. 缓存点赞总数到redisredisTemplate.opsForZSet().add(RedisConstants.LIKES_TIMES_KEY_PREFIX + recordDto.getBizType(), recordDto.getBizId().toString(), likedTimes);}@Overridepublic Set<Long> isBizLiked(List<Long> bizIds) {//1. 获取登录用户idLong userId = UserContext.getUser();//2. 查询点赞状态List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {StringRedisConnection src = (StringRedisConnection) connection;for (Long bizId : bizIds) {String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId;src.sIsMember(key, userId.toString());}return null;});//3. 返回结果Set<Long> set = new HashSet<>();for (int i = 0; i < objects.size(); i++) {Boolean o = (Boolean) objects.get(i);if (o) {set.add(bizIds.get(i));}}return set;}@Overridepublic void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {//1. 读取并移除redis中缓存的点赞总数String key = RedisConstants.LIKES_TIMES_KEY_PREFIX + bizType;Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);if (CollUtils.isEmpty(typedTuples)) {return;}//2.数据转换List<LikeTimesDto> list = new ArrayList<>(typedTuples.size());for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {String bizId = typedTuple.getValue();Double likedTimes = typedTuple.getScore();if (bizId == null || likedTimes == null) {continue;}list.add(LikeTimesDto.of(Long.parseLong(bizId), likedTimes.intValue()));}//3. 发送mq消息rabbitMqHelper.send(LIKE_RECORD_EXCHANGE,StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, bizType), list);}private boolean unlike(LikeRecordFormDTO recordDto) {//1. 获取用户idLong userId = UserContext.getUser();//2. 获取keyString key = RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDto.getBizId();//3. 执行SREM命令   如果有就删除成功返回1,如果没有就删除失败返回0Long result = redisTemplate.opsForSet().remove(key, userId.toString());return result != null && result > 0;}private boolean like(LikeRecordFormDTO recordDto) {//1. 获取用户idLong userId = UserContext.getUser();//2. 获取keyString key = RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDto.getBizId();//3. 执行SADD命令  如果没有就添加成功返回1,如果有就添加失败返回0Long result = redisTemplate.opsForSet().add(key, userId.toString());return result != null && result > 0;}
}
(2)批量查询点赞状态统计

 单个命令的执行流程:

    一次命令的响应时间 = 1次往返的网络传输耗时 + 1Redis执行命令耗时

N条命令依次执行:

N次命令的响应时间 = N次往返的网络传输耗时 + NRedis执行命令耗时

N条命令批量执行:

N次命令的响应时间 = 1次往返的网络传输耗时 + NRedis执行命令耗时

目前我们的Redis点赞记录数据结构如下:

KEY(bizId)

VALUE(userId)

bizId:1

userId:1

userId:2

userId:3

当我们判断某用户是否点赞时,需要使用下面命令:

# 判断用户是否点赞
SISMEMBER bizId userId

需要注意的是,这个命令只能判断一个用户对某一个业务的点赞状态。而我们的接口是要查询当前用户对多个业务的点赞状态。

因此,我们就需要多次调用SISMEMBER命令,也就需要向Redis多次发起网络请求,给网络带宽带来非常大的压力,影响业务性能。

那么,有没有办法能够一个命令完成多个业务点赞状态判断呢?

非常遗憾,答案是没有!只能多次执行SISMEMBER命令来判断。

不过,Redis中提供了一个功能,可以在一次请求中执行多个命令,实现批处理效果。这个功能就是Pipeline

中文文档:https://redis.com.cn/topics/pipelining.html

不要在一次批处理中传输太多命令,否则单次命令占用带宽过多,会导致网络阻塞

spring提供的RedisTemplate也具备pipeline功能,最终批量查询点赞状态功能实现如下:

@Override
public Set<Long> isBizLiked(List<Long> bizIds) {//1. 获取登录用户idLong userId = UserContext.getUser();//2. 查询点赞状态List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>)connection -> {StringRedisConnection src = (StringRedisConnection) connection;for (Long bizId : bizIds) {String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId;src.sIsMember(key, userId.toString());}return null;});//3. 返回结果Set<Long> set = new HashSet<>();for (int i = 0; i < objects.size(); i++) {Boolean o = (Boolean) objects.get(i);if (o) {set.add(bizIds.get(i));}}return set;
}
(3)定时任务

点赞成功后,会更新点赞总数并写入Redis中。而我们需要定时读取这些点赞总数的变更数据,通过MQ发送给业务方。这就需要定时任务来实现了。

首先,在tj-remark模块的RemarkApplication启动类上添加注解:

然后,定义一个定时任务处理器类:

代码如下:

@Component
@RequiredArgsConstructor
public class LikedTimesCheckTask {private static  final List<String> BIZ_TYPES = List.of("QA", "NOTE");private static final int MAAX_BIZ_SIZE = 50;private final ILikedRecordService recordService;@Scheduled(fixedDelay = 20000)public void checkLikedTimes(){for (String bizType:BIZ_TYPES){recordService.readLikedTimesAndSendMessage(bizType,MAAX_BIZ_SIZE);}}
}

由于可能存在多个业务类型,不能厚此薄彼只处理部分业务。所以我们会遍历多种业务类型,分别处理。同时为了避免一次处理的业务过多,这里设定了每次处理的业务数量为50,当然这些都是可以调整的。

真正处理业务的逻辑封装到了ILikedRecordService中:

void readLikedTimesAndSendMessage(String bizType, int maaxBizSize);

实现类:

@Override
public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {//1. 读取并移除redis中缓存的点赞总数String key = RedisConstants.LIKES_TIMES_KEY_PREFIX + bizType;Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);if (CollUtils.isEmpty(typedTuples)) {return;}//2.数据转换List<LikeTimesDto> list = new ArrayList<>(typedTuples.size());for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {String bizId = typedTuple.getValue();Double likedTimes = typedTuple.getScore();if (bizId == null || likedTimes == null) {continue;}list.add(LikeTimesDto.of(Long.parseLong(bizId), likedTimes.intValue()));}//3. 发送mq消息rabbitMqHelper.send(LIKE_RECORD_EXCHANGE, StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, bizType), list);
}
(4)监听点赞数变更

需要注意的是,由于在定时任务中一次最多处理20条数据,这些数据就需要通过MQ一次发送到业务方,也就是说MQ的消息体变成了一个集合

因此,作为业务方,在监听MQ消息的时候也必须接收集合格式。

我们修改tj-learning中的类com.tianji.learning.mq.LikeTimesChangeListener

@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {private final IInteractionReplyService replyService;@RabbitListener(bindings = @QueueBinding(value = @Queue(value = "qa.liked.times.queue", durable = "true"),exchange = @Exchange(name = MqConstants.Exchange.LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),key = QA_LIKED_TIMES_KEY))public void listenReplyLikedTimesChange(List<LikeTimesDto> likeTimesDto) {log.debug("监听到回答或评论的点赞数变更");List<InteractionReply> list = new ArrayList<>(likeTimesDto.size());for (LikeTimesDto dto : likeTimesDto) {InteractionReply r = new InteractionReply();r.setId(dto.getBizId());r.setLikedTimes(dto.getLikeTimes());list.add(r);}replyService.updateBatchById(list);}
}

http://www.dtcms.com/a/591051.html

相关文章:

  • 企业网站 响应式网站如何做整合营销
  • 公司做一个静态网站多少钱辽宁移动惠生活app官方版
  • wordpress 无法粘贴上海优化网站价格
  • 牛客面经八股题目----包含题解版
  • 网站如何防止攻击网页制作公司是做什么的
  • 网站建设下一步计划wordpress 属于多个栏目
  • 公司内部的网站主要作用井冈山保育院网站建设
  • 娄底建设网站制作广州工业设计公司有哪些
  • 网站备案流程审核单高校后勤网站建设要求及内容
  • C++面试题:Linux常用指令详解
  • 南山高端网站建设广州网站设计公司推荐哪家
  • 浙江省建设厅网站如何查安全员厦门网络推广公司
  • 网站建设案例教程做网站图片尺寸
  • pc三合一网站胶州收电脑号码是多少
  • 自学做网站学习建设网站难么
  • 16.Dify接入外部知识库
  • 百度给做网站收费多少钱韩国网站如何切换中文
  • 可以用AI做网站上的图吗昌平网站开发公司
  • 大学生可以做的网站网站如何被百度快速收录
  • php网站开发就业最便宜的购物软件排名
  • 苏州网站建设方案策划上海专业建设网站制作
  • dephi 网站开发杭州抖音代运营
  • 最小作用量原理MATLAB仿真
  • 济南做网站价格做网站设计
  • 网站的推广和优化方案智能在线设计
  • 北京微网站建设公司文化集团网站模板
  • 做h5游戏的网站网站开发企业培训
  • 高频面试八股文用法篇(十四)深度拷贝的几种实现方式
  • 建设银行官方投诉网站怎样做淘宝商品链接导航网站
  • 山东网站备案 论坛网站怎么申请百度小程序