天机学堂手撸
不认识的代码
Set<Long> uIds = list.stream().map(PointsBoard::getUserId).collect(Collectors.toSet());
这行代码的作用是从一个名为list
的列表中提取每个元素的用户ID(假设list
中的元素是PointsBoard
类型的对象),并将这些用户ID收集到一个Set<Long>
集合中。下面是这行代码的详细解释:
-
list.stream()
:将list
转换为一个流(Stream)。在Java中,流(Stream)是用于对集合(Collection)对象进行各种聚合操作(比如筛选、转换、聚合等)的工具。 -
.map(PointsBoard::getUserId)
:这是一个中间操作,用于将流中的每个元素(这里是PointsBoard
类型的对象)转换成另一个形式。这里使用了方法引用PointsBoard::getUserId
,意味着对流中的每个PointsBoard
对象调用其getUserId()
方法。这个方法应该返回一个Long
类型的用户ID。因此,这个操作的结果是一个新的流,流中的元素是用户ID(Long
类型)。 -
.collect(Collectors.toSet())
:这是一个终端操作,它处理流并返回一个结果。这里使用了Collectors.toSet()
收集器,它会把流中的所有元素收集到一个Set
中。由于Set
是一个不允许有重复元素的集合,所以最终得到的用户ID集合中不会有重复的用户ID。 -
以下是
.map
操作的一些常见用途: -
数据转换:你可以使用
.map
将流中的元素从一种类型转换为另一种类型。例如,将一个字符串流转换为整数流,或者将对象流中的某个字段提取出来形成一个新的流。 -
数据格式化:如果你需要对流中的元素进行格式化,
.map
也是一个很好的选择。比如,将日期对象转换为特定格式的字符串。 -
数据提取:当你需要从复杂对象中提取某个字段或属性时,
.map
可以简化这个过程。它允许你直接对流中的对象应用一个提取函数,而不是手动遍历列表并提取数据。 -
数据过滤的预处理:虽然
.filter
操作本身用于过滤数据,但有时你可能需要先转换数据,然后再进行过滤。在这种情况下,你可以使用.map
进行转换,然后链式调用.filter
进行过滤。 -
组合操作:
.map
可以与其他Stream操作组合使用,以构建复杂的数据处理管道。例如,你可以对流进行映射、过滤、排序和收集等一系列操作。
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
-
RedisConstants.POINTS_BOARD_KEY_PREFIX
:这是一个常量,用作键的前缀。在Redis中,使用前缀可以帮助你组织和管理数据,使得相关的键能够轻松地被识别和分组。这个前缀可能是一个字符串,比如"pointsBoard:"
,用于指示这个键与积分板(Points Board)相关。 -
now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER)
:这部分是键的后缀,它基于当前时间now
(假设now
是一个Date
对象)和一个特定的日期格式化器DateUtils.POINTS_BOARD_SUFFIX_FORMATTER
来生成。这个格式化器定义了如何将Date
对象转换为字符串,通常包括日期和/或时间信息。例如,格式化器可能配置为生成形如"20230401"
(年月日)或"20230401_1200"
(年月日_时分)的字符串。
将这两部分组合起来,key
可能看起来像这样:"pointsBoard:20230401"
或 "pointsBoard:20230401_1200"
,具体取决于DateUtils.POINTS_BOARD_SUFFIX_FORMATTER
的配置。
这种构造键的方式在Redis中很常见,特别是当你需要基于时间戳或日期来组织数据时。通过这种方式,你可以轻松地根据日期范围来查询、删除或过期旧的数据项。
注意,DateUtils.POINTS_BOARD_SUFFIX_FORMATTER
应该是一个DateTimeFormatter
对象,它是Java 8中引入的用于日期和时间格式化的新API的一部分。如果你正在使用Java 8或更高版本,那么使用DateTimeFormatter
是处理日期和时间格式化的推荐方式。
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet() .reverseRangeWithScores(key, from, from + pageSize - 1);
特别是,您正在调用reverseRangeWithScores
方法来获取有序集合中指定范围内的元素及其分数,并且这些元素和分数被封装在TypedTuple<String>
对象中,这些对象随后被收集到一个Set
中。
这里是代码的详细解释:
-
redisTemplate.opsForZSet()
: 这部分代码获取了RedisTemplate
中用于操作有序集合(ZSet)的操作接口。RedisTemplate
是Spring Data Redis提供的一个高级抽象,用于简化Redis数据的访问。 -
.reverseRangeWithScores(key, from, from + pageSize - 1)
: 这个方法用于获取有序集合中指定范围内的元素及其分数,范围是从from
(包含)到from + pageSize - 1
(包含)。由于方法名是reverseRangeWithScores
,所以返回的元素是按照分数从高到低的顺序排列的(即降序)。key
是有序集合在Redis中的键名,from
和from + pageSize - 1
定义了要检索的元素范围。 -
Set<ZSetOperations.TypedTuple<String>> tuples
: 这是方法调用的结果,它是一个Set
集合,包含了ZSetOperations.TypedTuple<String>
对象。每个TypedTuple
对象都封装了一个有序集合中的元素(在这里是String
类型)和该元素的分数。由于使用了Set
,所以返回的元素是唯一的,不会有重复。
需要注意的是,这里的String
类型是指有序集合中存储的元素类型。在Redis的有序集合中,元素和分数是成对存储的,元素可以是任何字符串,而分数是一个双精度浮点数(double),用于对元素进行排序。
此外,reverseRangeWithScores
方法返回的元素范围是基于索引的,索引从0开始。因此,from
是起始索引,而from + pageSize - 1
是结束索引,这样定义了一个包含pageSize
个元素的范围(如果范围内有足够的元素的话)。
当你使用TypedTuple<String>
时,你实际上是在指定元素(value)的类型为String
,而分数(score)的类型则由Redis有序集合的定义决定,与TypedTuple
的泛型参数无关。换句话说,TypedTuple
的泛型参数只影响元素值的类型,而不影响分数的类型。
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().reverseRangeWithScores("yourSortedSetKey", 0, -1);for (ZSetOperations.TypedTuple<String> tuple : tuples) {String value = tuple.getValue(); // 获取元素值(类型为String)double score = tuple.getScore(); // 获取分数(类型为double)// 处理元素值和分数
}
BoundZSetOperations<String, String> ops = redisTemplate.boundZSetOps(key);
在Spring Data Redis中,BoundZSetOperations
是一个接口,它提供了对特定Redis有序集合(sorted set)的绑定操作。当你通过RedisTemplate
的boundZSetOps
方法获取BoundZSetOperations
实例时,你实际上是在创建一个与特定Redis键(key)相关联的有序集合操作器。这个操作器允许你对该有序集合执行各种操作,如添加元素、删除元素、获取元素范围等,而无需每次都指定键名。
// 3.查询积分 Double points = ops.score(userId);
// 4.查询排名 Long rank = ops.reverseRank(userId);
public Integer querySeasonByTime(LocalDateTime time) { Optional<PointsBoardSeason> optional = lambdaQuery() .le(PointsBoardSeason::getBeginTime, time) .ge(PointsBoardSeason::getEndTime, time) .oneOpt(); return optional.map(PointsBoardSeason::getId).orElse(null); }
Optional<PointsBoardSeason> optional = lambdaQuery()
:首先,通过lambdaQuery()
方法开始构建一个Lambda查询。这个查询的返回类型被包装在Optional
中,Optional
是Java 8引入的一个容器类,用于包含非空值和null
值,以避免直接使用null
可能导致的NullPointerException
。.le(PointsBoardSeason::getBeginTime, time)
:添加一个条件,要求赛季的开始时间(beginTime
)小于或等于给定的时间time
。.ge(PointsBoardSeason::getEndTime, time)
:再添加一个条件,要求赛季的结束时间(endTime
)大于或等于给定的时间time
。.oneOpt();
:执行查询,并尝试获取一个满足条件的对象,结果封装在Optional
中。如果查询结果为空(即没有找到满足条件的对象),则Optional
为空;如果找到了一个对象,则Optional
包含该对象。
项目介绍
天机学堂是一个基于微服务架构的生产级在线教育项目,核心用户不是K12群体,而是面向成年人的非学历职业技能培训平台。相比之前的项目课程,其业务完整度、真实度、复杂度都非常的高,与企业真实项目非常接近。
通过天机学堂项目,你能学习到在线教育中核心的学习辅助系统、考试系统,电商类项目的促销优惠系统等等。更能学习到微服务开发中的各种热点问题,以及不同场景对应的解决方案。学完以后你会收获很多的“哇塞”。
系统架构
项目亮点
持续集成jenkins
jenkins
微服务项目可能有几十个jar包
自动打包,不需要等待,比如A调用B调用C,需要一个个等待,用了它只要放进仓库就可以自动实时打包
Day1
本地项目部署
dev是开发环境用在linux里,local是本地环境
整体认识
各个模块功能
代码规范
我们先来看看项目结构,目前企业微服务开发项目结构有两种模式:
-
1)项目下的每一个微服务,都创建为一个独立的Project,有独立的Git仓库,尽可能降低耦合
-
2)项目创建一个Project,项目下的每一个微服务都是一个Module,方便管理
方案一更适合于大型项目,架构更为复杂,管理和维护成本都比较高;
方案二更适合中小型项目,架构更为简单,管理和维护成本都比较低;
在天机学堂项目中,所有实体类按照所处领域不同,划分为4种不同类型:
-
DTO:数据传输对象,在客户端与服务端间传递数据,例如微服务之间的请求参数和返回值、前端提交的表单
-
PO:持久层对象,与数据库表一一对应,作为查询数据库时的返回值
-
VO:视图对象,返回给前端用于封装页面展示的数据
-
QUERY:查询对象,一般是用于封装复杂查询条件
如何替换autuwire注解
Spring提供了依赖注入的功能,方便我们管理和使用各种Bean,常见的方式有:
-
字段注入(
@Autowired
或@Resource
) -
构造函数注入
-
set方法注入
在以往代码中,我们经常利用Spring提供的@Autowired
注解来实现依赖注入:
配置文件
bootstrap.yml里写了active:dev说明默认是dev开发环境,到时候我们测试需要local
mysql,redis等等的配置都写在nacos,因为不止一个微服务用到,都用到了
这里username意思是你如果配了就用你自己的,没配就用默认root
异常处理
紧急bug链路追踪
bug解决
debug调试,找到这个子模块,右键,添加属性local配置文件,关掉liunx里的用户界面,然后打断点,发现是129超过了Long的范围导致不是同一个对象,!=自然判断失误,我们只需要值相等即可,所以改成equal。
如果不停止linux的服务,gateway不知道负载均衡到哪一台
因为idea启动了这个trade服务,linux就要停止trade服务
测试和部署
阅读获取登录用户代码
网关里校验token,token里有用户id,把它放进请求头
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {// 1.获取请求request信息ServerHttpRequest request = exchange.getRequest();String method = request.getMethodValue();String path = request.getPath().toString();String antPath = method + ":" + path;// 2.判断是否是无需登录的路径if(isExcludePath(antPath)){// 直接放行return chain.filter(exchange);}// 3.尝试获取用户信息List<String> authHeaders = exchange.getRequest().getHeaders().get(AUTHORIZATION_HEADER);String token = authHeaders == null ? "" : authHeaders.get(0);R<LoginUserDTO> r = authUtil.parseToken(token);// 4.如果用户是登录状态,尝试更新请求头,传递用户信息if(r.success()){exchange.mutate().request(builder -> builder.header(USER_HEADER, r.getData().getUserId().toString())).build();}// 5.校验权限authUtil.checkAuth(antPath, r);// 6.放行return chain.filter(exchange);}
但是业务层获取用户id就很麻烦,所以用mvc拦截器把用户id放进threadlocal,后面就可以随时取了
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.尝试获取头信息中的用户信息String authorization = request.getHeader(JwtConstants.USER_HEADER);// 2.判断是否为空if (authorization == null) {return true;}// 3.转为用户id并保存try {Long userId = Long.valueOf(authorization);UserContext.setUser(userId);return true;} catch (NumberFormatException e) {log.error("用户身份信息格式不正确,{}, 原因:{}", authorization, e.getMessage());return true;}}
但是拦截器没加注解怎么被扫到?因为配置了config(springboot特性)
网关微服务面试题
nginx调用网关再到微服务,微服务是在内网,不可直接访问
Day2
所以今天我们要完成的任务就是开发学习中心的《我的课程表》相关接口,让学员看到课程,然后才可以学习课程。
业务流程分析
这里用的是MQ,如果用feign不可以,因为feign是同步的,有危险
课程和用户是多对多,课表是中间表,有两个逻辑外键,因为三张表不在同一个数据库
接口设计
综上所述,接口设计的核心要素包括:
-
请求方式
-
请求路径
-
请求参数格式
-
返回值格式
知道了上述信息,前端就知道该向哪里发请求、请求要携带哪些参数、请求可以得到什么结果。而后端也能根据这些信息定义Controller接口、知道接口方式和路径、方法的参数、方法的返回值格式了。
但问题来了,上述要素我们该如何得知呢?
一般来说,可以按照下面的思路来设计:
-
请求方式和请求路径:这一部分只要遵循Restful风格即可
-
请求参数和返回值格式:结合页面原型和需求,与前端、后端、产品的同事共同协商决定。
这里比较复杂的就是参数和返回值,在分析的时候切忌自己臆想,不确定的地方一定要跟**产品经理反复确认,最好邮件确认**,避免以后扯皮。
然后与前端协商,或者跟调用你接口的后端同事协商。看页面渲染、其它服务需要哪些数据,而我们要查询这些数据需要哪些参数,最终确定接口的参数和返回值格式。
注意,上述过程不是一蹴而就的,很有可能会经过多次调整,这是非常正常的现象,核心思想就是一定要多沟通,多确认,不要自己任意妄为。
由于教学需要,我们的前端全部都已经开发完成,无法沟通协商来修改了。因此我们重点是根据页面原型来分析参数和返回值需要的字段。
具体到字段的名字,我会告诉大家,大家按照我给出的字段来设计即可(因为前端字段名称都已经写死)。
添加课程到课表
当用户支付完成或者报名免费课程后,应该立刻将课程加入到课表中。交易服务会通过MQ通知学习服务,我们需要查看交易服务的源码,查看MQ通知的消息格式,来确定监听消息的格式。
为什么这里要传用户id:因为mq异步,可能不是同一个线程,自然不能用threadlocal获得用户id
分页查询我的课表
查询一般用get,因为请求参数在路径上,如果请求参数复杂,可以用post请求,这样请求参数就可以放在请求体上了。
最近一次学习的课程
根据id查询指定课程学习状态
注意课表数据库表是所有学员公用的!!!!!
设计数据库
课表要记录的是用户的学习状态,所谓学习状态就是记录谁在学习哪个课程,学习的进度如何。
-
其中,谁在学习哪个课程,就是一种关系。也就是说课表就是用户和课程的中间关系表。因此一定要包含三个字段:
-
userId:用户id,也就是谁
-
courseId:课程id,也就是学的课程
-
id:唯一主键
-
-
而学习进度,则是一些附加的功能字段,页面需要哪些功能就添加哪些字段即可:
-
status:课程学习状态。0-未学习,1-学习中,2-已学完,3-已过期
-
准备工作
- 创建新分支!项目开发就是创建一个个的新分支,然后再合并!
- 远程仓库这个分支就没必要存在
-
枚举
- 这是一个Java枚举类型的定义。枚举类型是一种特殊的数据类型,它是一种有限个数的类类型。在这个例子中,定义了一个名为LessonStatus的枚举类型,它实现了BaseEnum接口。
枚举类型LessonStatus包括了几个枚举常量:NOT_BEGIN、LEARNING、FINISHED和EXPIRED,分别代表了课程状态中的未学习、学习中、已学完和已过期。每个枚举常量都有两个属性:value和desc,分别表示枚举常量的值和描述信息。
在枚举类型的构造方法中,通过传入value和desc参数来初始化枚举常量的属性。这样一来,你可以使用这个枚举类型来表示课程的不同状态,并且可以通过枚举常量的属性来获取对应的值和描述信息。 - 在你的枚举类型代码中,使用了三个注解:@Getter、@JsonValue和@EnumValue。每个注解的作用如下:
@Getter:
来自Lombok库。
自动生成getter方法,使你可以访问私有字段的值而不需要手动编写getter方法。
例如,对于value字段,Lombok会自动生成一个getValue()方法。
@JsonValue:
来自Jackson库,用于JSON序列化/反序列化。
标记这个注解的方法或字段将在序列化时作为枚举的表示形式。
在这个例子中,当你将LessonStatus枚举对象序列化为JSON时,value字段的值将用作该枚举对象的JSON表示。
例如,LessonStatus.NOT_BEGIN会序列化为0(因为NOT_BEGIN的value是0)。
@EnumValue:
通常用于MyBatis或其他ORM框架中,将枚举类型与数据库中的字段值进行关联。
标记这个注解的字段将被用作数据库中存储该枚举值的字段。
例如,当你将LessonStatus枚举对象存储到数据库中时,value字段的值会被存储,这样子你就可以根据数据库中的数值来还原枚举对象。 -
添加课程到课表
-
分析
-
代码
- 给子模块的一个确认消费类添加@Component注解加入spring容器
- 消费者端rabbitmq确认消息用注解
-
//交换机,队列,key@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)
因为用了mybatis-plus
-
public class LearningLessonServiceImpl extends ServiceImpl<LearningLessonMapper, LearningLesson>
继承了serviceimpl,所以下面可以直接this.saveBatch(list)操作数据库做插入操作
-
-
过期时间等于当前时间加课程有效期
课程信息通过调用函数传入OrderBasicDTO获取
@SuppressWarnings("ALL")
@Service
@RequiredArgsConstructor
@Slf4j
public class LearningLessonServiceImpl extends ServiceImpl<LearningLessonMapper, LearningLesson> implements LearningLessonService {private final CourseClient courseClient;@Override@Transactionalpublic void addUserLessons(Long userId, List<Long> courseIds) {
@SuppressWarnings("ALL")
: 这个注解告诉Java编译器忽略这个类中的所有警告。但通常,这是一个不太好的做法,因为它可能会隐藏一些潜在的问题。@Service
: 这是一个Spring的注解,它告诉Spring这个类是一个服务组件,Spring会将其自动检测并注册为Bean,这样其他组件就可以通过自动装配(如@Autowired
)来引用它。@RequiredArgsConstructor
: 这是Lombok库的一个注解。它会自动为类中所有final字段和任何被@NonNull
注解的字段生成一个构造函数。这意味着CourseClient
字段将自动有一个构造函数参数。@Slf4j
: 这也是Lombok库的一个注解。它会自动为类添加一个SLF4J的日志对象(通常是private static final Logger log = LoggerFactory.getLogger(MyClass.class);
)。这使得你可以在该类中使用log.info()
,log.error()
等方法来记录日志。@Transactional
: 这是一个Spring事务管理的注解。它告诉Spring这个方法需要在事务的上下文中运行。这意味着,如果在addUserLessons
方法执行过程中有任何异常抛出,那么该方法所做的所有数据库更改都将被回滚。-
分页查询我的课表
-
分析
- 数据库不存课程名称封面,但是可以获取课程id,然后在获取这些信息
- PageQuery作为参数存放前端来的请求参数
-
代码
- 查询我的课表操作返回值是一个PageDTO<LearninglessonVO>,因为是多条数据,每一条数据是一门课程VO。
@RestController
@RequestMapping("/lessons")
public class LearningLessonController {@GetMapping("/page")public PageDTO<LearningLessonVO> queryMyLessons(@Validated PageQuery query){return null;}}
-
@RestController:
- 这是一个特殊的
@Controller
注解,它告诉Spring这个类是一个控制器,并且所有方法默认返回的数据都会直接写入到HTTP响应体中,通常作为JSON或XML。 - 由于它是
@RestController
,因此你不需要在每个方法上都使用@ResponseBody
注解。
- 这是一个特殊的
在Spring MVC中,@RestController
注解是一个特殊的控制器注解,它实际上是一个组合注解,包含了@Controller
和@ResponseBody
。当你将一个类标记为@RestController
时,这意味着这个类中的所有方法都会默认使用@ResponseBody
注解的行为。
并使用@Validated
注解来确保该参数在绑定时通过验证(例如,如果PageQuery
类中有任何验证注解,如@NotNull
、@Min
等,那么这些验证将被执行
// 2.分页查询// select * from learning_lesson where user_id = #{userId} order by latest_learn_time limit 0, 5Page<LearningLesson> page = lambdaQuery().eq(LearningLesson::getUserId, userId) // where user_id = #{userId}.page(query.toMpPage("latest_learn_time", false));List<LearningLesson> lessonList = page.getRecords();if (CollUtils.isEmpty(lessonList)) {return PageDTO.empty(page);}
在您提供的代码片段中,您正在使用某种查询构建器(可能是MyBatis-Plus的LambdaQueryWrapper或类似的库)来构建一个查询,该查询用于从数据库中检索与特定用户ID相关联的LearningLesson
记录,并且这些记录会按照某个字段(如"latest_learn_time"
)进行分页排序。
下面是对您代码的详细解释:
-
lambdaQuery(): 这个方法可能是一个静态方法,用于创建一个新的LambdaQueryWrapper对象,这个对象允许您以Lambda表达式的方式构建查询条件。
-
.eq(LearningLesson::getUserId, userId): 这个方法调用向查询中添加了一个等于(
=
)条件,它比较LearningLesson
实体的userId
字段与给定的userId
是否相等。在SQL中,这大致相当于WHERE user_id = #{userId}
(其中#{userId}
是预处理的参数占位符)。 -
.page(query.toMpPage("latest_learn_time", false)): 这个方法调用设置了分页信息。这里假设
query
是一个包含分页信息的对象(可能是您自定义的或来自某个库的),而toMpPage
方法将这个信息转换为LambdaQueryWrapper可以理解的格式。"latest_learn_time"
可能是您希望按其进行排序的字段名,而false
可能表示是否进行降序排序(true
为降序,false
为升序)。 -
page.getRecords(): 最后,您从
page
对象中获取了实际的数据记录列表。这个列表包含了满足查询条件并且位于指定页码上的LearningLesson
对象。
pojo和vo的区别
这里的操作是从pojo对象Learninglesson找到课程id,通过课程id找到课程信息
List<CourseSimpleInfoDTO>courseList=courseClient.getSimpleInfoList(courseIds);
课程信息挑信息转化为vo对象LearninglessonVO对象
POJO(Plain Old Java Object)对象和VO(View Object)对象在Java编程中有着不同的定义和用途。以下是它们之间的主要区别:
- 定义与用途:
- POJO:是“Plain Old Java Object”的缩写,即简单的Java对象。它通常指的是没有遵循任何特殊规范或框架约束的JavaBean。POJO是一个普通的Java类,包含属性、getter和setter方法,以及可能的业务逻辑。在数据持久化中,POJO经常与数据库表结构相对应,用于映射数据模型。
- VO:是“View Object”的缩写,通常用于页面展示层。VO的作用是把某个指定页面(或组件)的数据封装起来,传输到前端页面上。VO是一个数据传输对象,用于在展示层和服务层之间传递数据。
- 生命周期:
- POJO:POJO的生命周期通常与应用程序的生命周期相同,它们是在程序运行时创建的,并由Java的垃圾回收机制(GC)管理其生命周期。
- VO:VO通常是用
new
关键字创建的,并由GC回收。它们的生命周期通常与业务逻辑的执行过程相关,一旦数据被展示或传输到目标位置,VO对象可能就不再需要了。
- 属性与业务逻辑:
- POJO:POJO的属性通常与数据库表的字段一一对应,它们代表了物理数据的对象表示。此外,POJO还可能包含部分业务逻辑的处理。
- VO:VO的属性则根据当前业务的不同而不同,它的每一个属性都一一对应当前业务逻辑所需要的数据的名称。VO更多地关注数据的展示和传输,而不涉及业务逻辑的处理。
- 不变性:
- POJO:POJO通常不是不可变的,其属性值可以在创建后进行修改。
- VO:在某些情况下,VO被视为一种值对象(Value Object),这意味着VO一旦创建,其值通常是不可变的。这有助于保持数据的完整性和一致性。
- 使用场景:
- POJO:在数据持久化、ORM(对象关系映射)和数据库交互等场景中广泛使用。
- VO:在Web应用程序的前后端交互、数据展示和传输等场景中广泛使用。
//courseList转成map,key是课程id,value就是课程对象Map<Long,CourseSimpleInfoDTO> courseMap=courseList.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId,c->c));
-
courseList.stream():
这将courseList
(一个List<CourseSimpleInfoDTO>
对象)转换成一个Stream,以便进行后续的流式操作。 -
.collect(Collectors.toMap(...)):
使用collect
操作和Collectors.toMap()
收集器,你将Stream中的元素收集到一个Map中。Collectors.toMap()
接受两个参数:- 第一个参数是一个函数,用于从Stream中的元素提取键(key)。在这里,你使用了方法引用
CourseSimpleInfoDTO::getId
,这意味着它将从每个CourseSimpleInfoDTO
对象中提取id
属性作为Map的键。 - 第二个参数是一个函数,用于从Stream中的元素提取值(value)。在这里,你使用了
c -> c
,这是一个lambda表达式,它简单地返回Stream中的当前元素(即CourseSimpleInfoDTO
对象本身)作为Map的值。
- 第一个参数是一个函数,用于从Stream中的元素提取键(key)。在这里,你使用了方法引用
注意,Collectors.toMap()
默认的行为是在遇到键冲突时抛出IllegalStateException
。也就是说,如果courseList
中有两个或更多的CourseSimpleInfoDTO
对象具有相同的id
,那么这段代码将会抛出异常。
为什么把courseId转成map?
// 4.3.获取课程信息,填充到vo CourseSimpleInfoDTO cInfo = courseMap.get(r.getCourseId());
因为这行代码,如果转成map,可以直接拿,不然还得比较r(Learninglesson)的id和课程的id,才能拿出课程对象CourseSimpleInfoDTO
查询最近正在学习的课程
分析
通过查数据库里的最近一次的课程id来查最近一次学习的课程的小节名称和小节序号
怎么查最近一次?
// 2.查询正在学习的课程 select * from learning_lesson where user_id = #{userId} AND status = 1 order by latest_learn_time desc limit 1
LearningLesson lesson = lambdaQuery()
.eq(LearningLesson::getUserId, userId)
.eq(LearningLesson::getStatus, LessonStatus.LEARNING.getValue())
.orderByDesc(LearningLesson::getLatestLearnTime)
.last("limit 1")
.one();
注意:这里查的是数据库表,返回的是pojo对象LearningLesson
mp提供了lambda语法代替编写sql语句
在这段代码中,你展示了两种查询正在学习的课程的方法:一种是使用原生的SQL语句,另一种是使用某种ORM(对象关系映射)框架(很可能是MyBatis-Plus或类似的框架)的Lambda查询方式。这两种方法虽然在语法和表达方式上有所不同,但它们的目的都是相同的,即查询满足特定条件的记录。
为什么Lambda查询可以替代SQL语句呢?主要有以下几个原因:
- 类型安全:Lambda查询使用Java的泛型和方法引用来构建查询条件,这提供了类型安全的好处。你不需要担心字段名拼写错误或类型不匹配的问题,因为编译器会在编译时进行检查。
- 可读性:对于熟悉Java和Lambda表达式的开发者来说,Lambda查询往往比SQL语句更具可读性。你可以直接使用Java对象和方法名来构建查询,而不需要编写SQL语句中的字符串字面量。
- 可维护性:由于Lambda查询与Java代码紧密集成,因此它们可以更容易地利用Java的IDE工具进行重构、重命名和查找引用等操作。此外,Lambda查询也更容易与Java的其他部分(如业务逻辑和验证)集成。
- 框架支持:现代ORM框架通常提供了丰富的功能来支持Lambda查询,包括动态查询、分页、排序和条件组合等。这些功能使得Lambda查询比原生SQL语句更加强大和灵活。
- 避免SQL注入:使用ORM框架的Lambda查询通常可以避免SQL注入攻击,因为查询条件是在运行时由框架动态生成的,而不是直接拼接SQL字符串。这降低了应用程序遭受恶意输入的风险。
在你给出的示例中,lambdaQuery()
方法可能是一个自定义的或框架提供的方法,用于构建Lambda查询。然后,你使用eq
、orderByDesc
和last
等方法来指定查询条件、排序方式和限制结果数量。最后,one()
方法执行查询并返回第一个结果(或在没有结果时返回null)。这种方法与原生SQL语句具有相同的效果,但提供了更好的类型安全、可读性和可维护性。
根据id查询指定课程的学习状态
分析
看看课程是否被购买
代码
检查课程是否有效
@GetMapping("/{courseId}/valid")为什么courseid在中间,要加{}
Day3
准备工作:合并分支提交
先在feature-lesson分支commit,然后切换到dev分支(checkout),然后点击feature-lesson分支合并
然后创建一个新分支用来写代码
new branch
接口统计
设计数据库
学习记录是以小节为单位,但是数据库要有课表id因为可能不同课程有同一个名字的小节
创建学习计划
分析
代码
DTO是接收前端传来的数据保存起来的实体类型
枚举和interger比较问题
查询指定课程学习记录
分析
其中,课程、章节、目录信息等数据都在课程微服务,而学习进度肯定是在学习微服务。课程信息是必备的,而学习进度却不一定存在。
因此,查询这个接口的请求肯定是请求到课程微服务,查询课程、章节信息,再由课程微服务向学习微服务查询学习进度,合并后一起返回给前端即可。
所以,学习中心要提供一个查询章节学习进度的Feign接口,事实上这个接口已经在tj-api模块的LearningClient中定义好了
返回值在不同的微服务下,有两种方法,一是前端发起两次请求,二是发起一次,后端内部来调用别的微服务,这里我们采用第二种同时是课程服务调用学习服务,因为课程服务多,然后返回值是一个DTO而且不在学习服务里,因为这样课程服务就用不了了,而是放在tj-api里
代码
一个学员加一个课程对应一个课表(课程学习情况),而之前说的课表数据库公用是不同学员的不同课程的课表。
其实就是同一个模块下一个类调另一个类
提交学习记录
分析
只要记录了用户学过的每一个小节,以及小节对应的学习进度、是否学完。无论是视频续播、还是统计学习计划进度,都可以轻松实现了。
代码
提交视频
private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDTO) {// 1.查询旧的学习记录LearningRecord old = lambdaQuery().eq(LearningRecord::getLessonId, recordDTO.getLessonId()).eq(LearningRecord::getSectionId, recordDTO.getSectionId()).one();// 2.判断是否存在if (old == null) {// 3.不存在,则新增// 3.1.转换POLearningRecord record = BeanUtils.copyBean(recordDTO, LearningRecord.class);// 3.2.填充数据record.setUserId(userId);// 3.3.写入数据库boolean success = save(record);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 ;}
首先,提交是post操作,携带的表单从前端获取,传给后端,也就是LearningRecordFormDto接收
学习记录和课表有什么关系:
不同的表
考试直接返回true,因为考试一旦提交就是考试完成,而视频经过判断返回finished,因为视频提交不一定是完成
查询学习计划和进度
分析
查询的是我的学习计划,隐含的查询条件就是当前登录用户,这个无需传递,通过请求头即可获得。
代码
查询当前用户本周已经学完的小节数量:在lesson_record表里查
查询当前用户本周计划学习小节数:在课表里查
分页参数
请求参数叫做分页参数
@Data
@ApiModel(description = "分页请求参数")
@Accessors(chain = true)
public class PageQuery {public static final Integer DEFAULT_PAGE_SIZE = 20;public static final Integer DEFAULT_PAGE_NUM = 1;@ApiModelProperty(value = "页码", example = "1")@Min(value = 1, message = "页码不能小于1")private Integer pageNo = DEFAULT_PAGE_NUM;@ApiModelProperty(value = "每页大小", example = "5")@Min(value = 1, message = "每页查询数量不能小于1")private Integer pageSize = DEFAULT_PAGE_SIZE;@ApiModelProperty(value = "是否升序", example = "true")private Boolean isAsc = true;@ApiModelProperty(value = "排序字段", example = "id")private String sortBy;public int from(){return (pageNo - 1) * pageSize;}public <T> Page<T> toMpPage(OrderItem ... orderItems) {Page<T> page = new Page<>(pageNo, pageSize);// 是否手动指定排序方式if (orderItems != null && orderItems.length > 0) {for (OrderItem orderItem : orderItems) {page.addOrder(orderItem);}return page;}// 前端是否有排序字段if (StringUtils.isNotEmpty(sortBy)){OrderItem orderItem = new OrderItem();orderItem.setAsc(isAsc);orderItem.setColumn(sortBy);page.addOrder(orderItem);}return page;}public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc) {if (StringUtils.isBlank(sortBy)){sortBy = defaultSortBy;this.isAsc = isAsc;}Page<T> page = new Page<>(pageNo, pageSize);OrderItem orderItem = new OrderItem();orderItem.setAsc(this.isAsc);orderItem.setColumn(sortBy);page.addOrder(orderItem);return page;}public <T> Page<T> toMpPageDefaultSortByCreateTimeDesc() {return toMpPage(Constant.DATA_FIELD_NAME_CREATE_TIME, false);}
}
查询当前用户某个课程本周学习小节数:要用课表id查而不是课程id查
查当前用户课表会查到很多条课表,先用Page接收再用List封装
封装需要注意sql写法
设置别名和dto实体类一致
//遍历课表list封装成voListList<LearningPlanVO> voList=new ArrayList<>();for (LearningLesson lesson:records){//每遍历一次就要封装成一个learningplanvoLearningPlanVO planVO=BeanUtils.copyBean(lesson,LearningPlanVO.class);//封装课程信息CourseSimpleInfoDTO course=courseMap.get(lesson.getCourseId());if (course!=null){planVO.setCourseName(course.getName());planVO.setSections(course.getSectionNum());}//封装已经学习的小节数据planVO.setWeekLearnedSections(idAndNumMap.getOrDefault(lesson.getId(),0));voList.add(planVO);}
补充说明表的意义
learning_lesson存的是课表信息,就是一门课的信息
learning_record存的是一个小节的信息,一门课有很多小节
DAY4 高并发优化方案
提高单机并发能力
对于读多写少的业务,其优化手段大家都比较熟悉了,主要包括两方面:
-
优化代码和SQL
-
添加缓存
对于写多读少的业务,大家可能较少碰到,优化的手段可能也不太熟悉,这也是我们要讲解的重点。
对于高并发写的优化方案有:
-
优化代码及SQL
-
变同步写为异步写
-
合并写请求
-
合并写请求优化播放记录提交
因此,缓存中至少要包含3个字段:
-
记录id:id,用于根据id更新数据库
-
播放进度:moment,用于缓存播放进度
-
播放状态(是否学完):finished,用于判断是否是第一次学完
既然一个小节要保存多个字段,是不是可以考虑使用Hash结构来保存这些数据
不过,这样设计有一个问题。课程有很多,每个课程的小节也非常多。每个小节都是一个独立的KEY,需要创建的KEY也会非常多,浪费大量内存。
而且,用户学习视频的过程中,可能会在多个视频之间来回跳转,这就会导致频繁的创建缓存、缓存过期,影响到最终的业务性能。该如何解决呢?
既然一个课程包含多个小节,我们完全可以把一个课程的多个小节作为一个KEY来缓存
这样做有两个好处:
-
可以大大减少需要创建的KEY的数量,减少内存占用。
-
一个课程创建一个缓存,当用户在多个视频间跳转时,整个缓存的有效期都会被延续,不会频繁的创建和销毁缓存数据
添加缓存以后,学习记录提交的业务流程就需要发生一些变化了
变化最大的有两点:
-
提交播放进度后,如果是更新播放进度则不写数据库,而是写缓存
-
需要一个定时任务,定期将缓存数据写入数据库
这样设计可以避免同一个用户学同一个课程的同一个小节问题,因为他们有不同的课程id
key是课表id,hashkey是小节id
每当前端提交播放记录时,我们可以设置一个延迟任务并保存这次提交的进度。等待20秒后(因为前端每15秒提交一次,20秒就是等待下一次提交),检查Redis中的缓存的进度与任务中的进度是否一致。
-
不一致:说明持续在提交,无需处理
-
一致:说明是最后一次提交,更新学习记录、更新课表最近学习小节和时间到数据库中
DelayQueue介绍
时间一般用纳秒,保证数据传输更准确
class DelayTaskTest {@Testvoid testDelayQueue() 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());}}
}
volatile和@PostConstruct、
这个注解的意义就是当spring创建了LearningLessonServiceImpl对象时就会执行这个函数,销毁了就执行下面的函数
如果子线程的flag不用volatile修饰,主线程改了flag是不对子线程可见的。
工具类1
查询redis缓存的一个方法,通过key和hashkey来查数据,然后转换成java类LearningRecord
流程梳理
// 项目启动后,当前类实例化 属性注入值后 该方法会运行,一般用于初始化工作
@PostConstruct
public void init(){
log.info("init方法执行了");
CompletableFuture.runAsync(this::handleDelayTask);
}
异步调用handleDelayTask,因为
/*** 处理延时任务*/private void handleDelayTask(){while (begin){try {// 1.尝试获取任务,poll:非阻塞方法,take非阻塞方法DelayTask<RecordTaskData> task = queue.take();RecordTaskData data = task.getData();// 2.读取Redis缓存LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId());log.debug("获取到要处理的播放记录任务,任务数据:{},缓存数据:{}",task.getData(),record);if (record == null) {continue;}// 3.比较新提交的延迟任务的视频播放进度数值和redis缓存中的是否一致if(!Objects.equals(data.getMoment(), record.getMoment())){// 4.如果不一致,播放进度在变化,无需持久化continue;}// 5.如果一致,证明用户离开了视频,需要持久化// 5.1.更新学习记录record.setFinished(null);recordMapper.updateById(record);// 5.2.更新课表LearningLesson lesson = new LearningLesson();lesson.setId(data.getLessonId());lesson.setLatestSectionId(data.getSectionId());lesson.setLatestLearnTime(LocalDateTime.now());lessonService.updateById(lesson);log.debug("准备持久化学习记录信息");} catch (Exception e) {log.error("处理播放记录任务发生异常", e);}}}
会从阻塞队列里拿东西,一开始没东西,就卡在那了 (注意:一开始把begin设置成volatile)
record.setFinished(null);的目的是不一定学完,这样以防万一,设置为null下面的sql语句就不会出现这一列
redis对同一小节的缓存会覆盖,因为是同一个key和hashkey
@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();}}
这个实体类是存进redis的,id是学习记录的id
@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();}}
这个实体类是存进延迟队列的
/*** 提交学习记录** @param dto 学习记录表单*/@Overridepublic void submitLearningRecord(LearningRecordFormDTO dto) {// 获取当前登录用户Long userId = UserContext.getUser();// 处理学习记录boolean finished = false;if (dto.getSectionType().equals(SectionType.EXAM)) {// 提交考试记录finished = handleExamRecord(userId, dto);} else {// 提交视频播放记录finished = handleVideoRecord(userId, dto);}// 如果本小节不是首次学完,由于使用了异步延迟任务,不需要往下执行if (!finished) {return;}// 处理课表数据handleLessonData(dto);}
异步队列里已经对课表进行了修改
/*** 处理该小节视频播放记录** @param userId 用户id* @param dto 学习记录DTO* @return 是否已完成该小节*/private boolean handleVideoRecord(Long userId, LearningRecordFormDTO dto) {// 查询该小节视频进度记录是否已存在,根据lessonId和sectionId进行匹配LearningRecord oldRecord = queryOldRecord(dto.getLessonId(), dto.getSectionId());// 根据查询结果来判断是新增还是删除if (oldRecord == null) {// po转dtoLearningRecord learningRecord = BeanUtil.toBean(dto, LearningRecord.class);// 视频播放小节是否已完成根据learningRecord.setUserId(userId);// 保存到Learning-record表// 由于前段每15秒发送提交学习记录请求,所以新增时默认未完成boolean result = this.save(learningRecord);if (!result) {throw new DbException("新增视频播放记录失败");}// 返回false是因为新增return false;}// 判断本小节是否是首次完成:之前未完成且视频播放进度大于50%boolean isFinished = !oldRecord.getFinished() && dto.getMoment() * 2 >= dto.getDuration();// 更新视频播放进度,根据主键id进行匹配if (!isFinished) {LearningRecord record = LearningRecord.builder().id(oldRecord.getId()).lessonId(dto.getLessonId()).sectionId(dto.getSectionId()).finished(oldRecord.getFinished()).moment(dto.getMoment()).build();// 添加指定学习记录到redis,并提交延迟任务到延迟队列DelayQueuetaskHandler.addLearningRecordTask(record);// 返回,本小节未完成return false;}boolean result = this.lambdaUpdate().set(LearningRecord::getMoment, dto.getMoment())// 只有首次完成视频播放才更新finished字段和finish_time字段.set(LearningRecord::getFinished, true).set(LearningRecord::getFinishTime, dto.getCommitTime()).eq(LearningRecord::getId, oldRecord.getId()).update();if (!result) {throw new DbException("更新视频播放记录失败");}// 清理redis相应recordtaskHandler.cleanRecordCache(dto.getLessonId(), dto.getSectionId());return true;}/*** 查询指定学习记录是否已存在,*/private LearningRecord queryOldRecord(Long lessonId, Long sectionId) {// 查询redis缓存LearningRecord cacheRecord = taskHandler.readRecordCache(lessonId, sectionId);// redis缓存命中if (cacheRecord != null) {return cacheRecord;}// redis缓存未命中,查询数据库LearningRecord dbRecord = this.lambdaQuery().eq(LearningRecord::getLessonId, lessonId).eq(LearningRecord::getSectionId, sectionId).one();// 数据库查询结果为null,表示记录不存在,需要新增学习记录,返回null即可if (dbRecord == null) {return null;}// 数据库查询结果写入redis缓存taskHandler.writeRecordCache(dbRecord);return dbRecord;}
形参先传入LessonRecord(包含的数据可以给上面两个不同的实体),然后执行添加redis缓存函数,函数内封装了RecordCacheData,把LessonRecord的部分数据封装进去然后存进redis,延迟任务里封装的类作用是在处理延迟任务函数中取出类中的lessonid和sectionid再去查询redis。
异步在最开始容器创建的时候执行一个函数,里面是while(flag)死循环,也就是一个线程一直在那取延迟队列里的数据进行下面的操作,延迟队列存的是数据,做操作的在延迟队列之外的异步线程
再升级之线程池的使用
目前我们的延迟任务执行还是单线程模式,大家将其改造为线程池模式
// 线程池private static ExecutorService executor = null;// 项目启动后,当前类实例化 属性注入值后 该方法会运行,一般用于初始化工作@PostConstructpublic void init() {log.info("init方法执行了");// 核心线程数等于CPU核心数Integer corePoolSize = Runtime.getRuntime().availableProcessors();// 创建线程池executor = Executors.newFixedThreadPool(corePoolSize);CompletableFuture.runAsync(this::handleDelayTask);// executor.execute(this::handleDelayTask);}// 项目销毁前后,关闭延迟队列@PreDestroypublic void destroy() {log.debug("关闭学习记录处理的延迟任务");// 关闭线程池executor.shutdown();begin = false;}/*** 处理延时任务*/private void handleDelayTask() {while (begin) {try {// 1.尝试获取任务,poll:非阻塞方法,take非阻塞方法DelayTask<RecordTaskData> task = queue.take();executor.submit(()->{RecordTaskData data = task.getData();// 2.读取Redis缓存
面试
面试官:你在开发中参与了哪些功能开发让你觉得比较有挑战性?
答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。我们网站的课程是以录播视频为主,为了提高用户的学习体验,需要实现视频续播功能。这个功能本身并不复杂,只不过我们产品提出的要求比较高:
-
首先续播时间误差要控制在30秒以内。
-
而且要做到用户突然断开,甚至切换设备后,都可以继续上一次播放
要达成这个目的,使用传统的手段显然是不行的。
首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。
其次,用户突然断开或者切换设备,续播的时间误差不能超过30秒,那播放进度的记录频率就需要比较高。我们会在前端每隔15秒就发起一次心跳请求,提交最新的播放进度,记录到服务端。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在15秒左右。
面试官:那播放进度在服务端保存在哪里呢?是数据库吗?如果是数据库,如何解决高频写入给数据库带来巨大压力?
答:
提交播放记录最终肯定是要保存到数据库中的。因为我们不仅要做视频续播,还有用户学习计划、学习进度统计等功能,都需要用到用户的播放记录数据。
但确实如你所说,前端每隔15秒一次请求,如果在用户量较大时,直接全部写入数据库,对数据库压力会比较大。因此我们采用了合并写请求的方案,当用户提交播放进度时会先缓存在Redis中,后续再将数据保存到数据库即可。
由于播放进度会不断覆盖,只保留最后一次即可。这样就可以大大减少对于数据库的访问次数和访问频率了。
DAY5问答系统
流程总结
老师和学生回答的一个在管理端一个在用户端
接口统计
回答针对问题,评论针对回答
数据库设计
结合ER图,表结构就非常清楚了,会包含两张表:
-
问题表
-
回复表:回答和评论都是回复,在一张表
新增互动问题
分页查询问题
左边是数据库有的,右边是页面展示需要的
提问者和回答者封装到同一个map,不会乱的,因为有不同的id
Set<Long> userIds=new HashSet<>();Set<Long> answerIds=new HashSet<>();
上面先存提问者的用户id,一个课程有很多提问
下面存最新的回答id,每个问题都会有最新的回答
然后查数据库,封装map存回答id和关于回答信息封装的一个类(replay)
根据repla查询对应的回答者用户id也存进上面的set
然后就是第四步查询用户的信息,也用map装
userIds.remove(null);去除色图里的null值
管理端分页查询问题
课程名称模糊查询
代码(es加jvm本地缓存)
caffeine(*****)
工具类
搜索业务
DAY6点赞系统
数据库设计
不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:
-
点赞对象类型(为了通用性)
设计点赞或者取消点赞
代码(只是发送mq,还没写消费者mq)
@Overridepublic void addLikeRecord(LikeRecordFormDTO recordDTO) {// 1.基于前端的参数,判断是执行点赞还是取消点赞boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);// 2.判断是否执行成功,如果失败,则直接结束if (!success) {return;}// 3.如果执行成功,统计点赞总数Integer likedTimes = lambdaQuery().eq(LikedRecord::getBizId, recordDTO.getBizId()).count();// 4.发送MQ通知mqHelper.send(LIKE_RECORD_EXCHANGE,StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, recordDTO.getBizType()),LikedTimesDTO.of(recordDTO.getBizId(), likedTimes));}private boolean unlike(LikeRecordFormDTO recordDTO) {return remove(new QueryWrapper<LikedRecord>().lambda().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 r = new LikedRecord();r.setUserId(userId);r.setBizId(recordDTO.getBizId());r.setBizType(recordDTO.getBizType());save(r);return true;}
}
发送mq通知需要对key进行拼接,因为这是通用点赞系统,对不同业务有不同的key。
此时点赞记录不会变化,消费者还没写
监听点赞信息
mq发送的是likedtimesdto
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {private final IInteractionReplyService replyService;@RabbitListener(bindings = @QueueBinding(value = @Queue(name = "qa.liked.times.queue", durable = "true"),exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),key = QA_LIKED_TIMES_KEY))
这段代码配置了一个监听器,该监听器监听名为 "qa.liked.times.queue"
的队列上的消息。这个队列通过主题交换机 LIKE_RECORD_EXCHANGE
和路由键 QA_LIKED_TIMES_KEY
来接收消息。当交换机收到匹配路由键的消息时,它会将这些消息路由到 "qa.liked.times.queue"
队列,进而被 @RabbitListener
注解的方法处理。
查询点赞状态(当前用户对不同业务的点赞状况)
根据业务id,业务类型,用户id来查询点赞数
这是一个feign接口,因为问答模块查出了用户的回答要远程调用看见回答的点赞数
***暴露feign接口(实现spi加载feign调用失败的fallback机制!!!)
改进
改进后
redis数据结构选择
点赞记录
点赞数
而且定时任务间隔期间如果来了很多点赞,需要批量处理,比如1每次处理200个,hash无序,拿不出来规定的数量
@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);}private boolean unlike(LikeRecordFormDTO recordDTO) {// 1.获取用户idLong userId = UserContext.getUser();// 2.获取KeyString key = RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDTO.getBizId();// 3.执行SREM命令Long 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命令Long result = redisTemplate.opsForSet().add(key, userId.toString());return result != null && result > 0;}
点赞成功统计总数缓存到redis里而不是发送mq然后对数据库操作
上面是点赞记录下面是点赞次数
定时任务
视频提交有最后一次,而点赞没有最后一个点赞,所以我们用定时任务
/*** 从redis取指定类型点赞数量并发送消息到RabbitMQ** @param bizType 业务类型* @param maxBizSize 每次任务取出的业务score标准*/@Overridepublic void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {// 拼接keyString key = RedisConstants.LIKE_COUNT_KEY_PREFIX + bizType;// 读取redis,从zset(按score排序)中取出点赞信息,弹出score小于maxBizSizeSet<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);if (CollUtil.isNotEmpty(typedTuples)) {List<LikedTimesDTO> likedTimesDTOS = new ArrayList<>(typedTuples.size());for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {Double likedTimes = tuple.getScore(); // 获取点赞数量String bizId = tuple.getValue(); // 获取业务id// 校验是否为空if (StringUtils.isBlank(bizId) || likedTimes == null) {continue;}// 封装LikedTimeDTOLikedTimesDTO likedTimesDTO = LikedTimesDTO.builder().bizId(Long.valueOf(bizId)).likedTimes(likedTimes.intValue()).build();likedTimesDTOS.add(likedTimesDTO);}// 发送RabbitMQ消息if (CollUtil.isNotEmpty(likedTimesDTOS)){log.info("发送点赞消息,消息内容:{}", likedTimesDTOS);rabbitMqHelper.send(MqConstants.Exchange.LIKE_RECORD_EXCHANGE,StringUtils.format(MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE, bizType),likedTimesDTOS);}}}
在你的代码片段中,你首先构建了一个Redis有序集合(ZSet)的键,然后尝试从这个有序集合中弹出分数(score)最小的maxBizSize
个元素。但是,这里有一个需要注意的点:redisTemplate.opsForZSet().popMin(key, maxBizSize);
这一行代码实际上在标准的Spring Data Redis中并不存在。Redis的ZSet命令集中并没有直接支持“弹出并返回”多个最小元素的命令。通常,我们会使用range
或reverseRange
方法来获取元素,但这两个方法不会从集合中移除元素。
Set<ZSetOperations.TypedTuple<String>>
:Set
: 这是一个Java集合接口,用于存储不重复的元素。在这个上下文中,它用于存储从Redis有序集合中检索到的元素及其分数的组合。ZSetOperations.TypedTuple<String>
: 这是一个泛型类型,表示Redis有序集合中的一个元素及其分数的组合。TypedTuple
是Spring Data Redis中定义的一个接口,用于封装有序集合中的成员(member)和它的分数(score)。这里的String
表示成员(member)的类型是字符串。
监听点赞数变更
需要注意的是,由于在定时任务中一次最多处理20条数据,这些数据就需要通过MQ一次发送到业务方,也就是说MQ的消息体变成了一个集合:
@RabbitListener(bindings = @QueueBinding(value = @Queue(value = "qa.liked.times.queue", durable = "true"),exchange = @Exchange(value = MqConstants.Exchange.LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),key = MqConstants.Key.QA_LIKED_TIMES_KEY))public void onMsg(List<LikedTimesDTO> dtoList) {log.info("LikeRecordChangeListener监听到消息,消息内容:{}", dtoList);// 封装到list以执行批量更新List<InteractionReply> replyList = new ArrayList<>();for (LikedTimesDTO dto : dtoList) {InteractionReply reply = new InteractionReply();reply.setId(dto.getBizId()); // 业务idreply.setLikedTimes(dto.getLikedTimes()); // 点赞数量replyList.add(reply);}// 批量更新replyService.updateBatchById(replyList);}
查询点赞状态(管道流)
DAY7积分系统
签到业务(bitmap)
bitmap
0是8位中的第一位,代表一号。
bitfield用法
分析
右移就是>>
代码(签到)
// 1.签到// 1.1.获取登录用户Long userId = UserContext.getUser();// 1.2.获取日期LocalDate now = LocalDate.now();// 1.3.拼接keyString key = RedisConstants.SIGN_RECORD_KEY_PREFIX+ userId+ now.format(DateUtils.SIGN_DATE_SUFFIX_FORMATTER);// 1.4.计算offsetint offset = now.getDayOfMonth() - 1;// 1.5.保存签到信息Boolean exists = redisTemplate.opsForValue().setBit(key, offset, true);if (BooleanUtils.isTrue(exists)) {throw new BizIllegalException("不允许重复签到!");}
这行代码使用redisTemplate
(一个用于操作Redis的模板类)的opsForValue().setBit()
方法,在Redis中设置位图的值。参数解释如下:
key
:之前构造的Redis键,用于标识用户的签到记录。offset
:当前日期对应的位图中的位置(即偏移量)。true
:表示用户已经签到,因此将这个位置的位设置为1。exists
:这个方法的返回值是一个布尔值,但在这种情况下,它实际上并不直接表示“是否存在”的意思,因为setBit
操作总是会执行,不管这个位置之前是否有值。不过,如果你想检查这个位之前是否被设置过(即用户之前是否签到过),你可能需要使用不同的方法(如getBit
)。
怎么判断重复签到:发送修改redis,那个方法会返回原来的数字,如果是1则重复签到
代码(统计本月连续签到次数)
private int countSignDays(String key, int len) {// 1.获取本月从第一天开始,到今天为止的所有签到记录List<Long> result = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(len)).valueAt(0));if (CollUtils.isEmpty(result)) {return 0;}int num = result.get(0).intValue();// 2.定义一个计数器int count = 0;// 3.循环,与1做与运算,得到最后一个bit,判断是否为0,为0则终止,为1则继续while ((num & 1) == 1) {// 4.计数器+1count++;// 5.把数字右移一位,最后一位被舍弃,倒数第二位成了最后一位num >>>= 1;}return count;}
在Java中,当你对一个整数(无论是int
、long
还是其他整型)执行无符号右移操作(>>>
)时,你实际上是在将数字的二进制表示向右移动指定的位数,同时在左侧填充0(而不是符号位)。这种操作在处理位图或进行位级操作时非常有用。
对于你提供的代码片段,它首先从Redis中获取一个位图字段的值(可能是一个表示签到天数的无符号整数),然后通过一个循环来统计这个整数中最低位开始连续为1的位数。这是通过不断将整数与1进行按位与操作(& 1
),并每次循环将整数无符号右移一位(>>>= 1
)来实现的。
关于你提到的“怎么做到一个int可以一直>>>的”,实际上,在Java中,int
类型是一个32位的整数,因此你可以对它执行最多32次的无符号右移操作,每次操作都会将数值向右移动一位,并在左侧填充0。当执行了32次无符号右移之后,int
值将变为0,因为所有的位都已经被移出了整数的表示范围。
查询我的本月签到记录
用于签到显示页面的高亮色差
public List<Byte> selectMonthSignRecords() {// 获取当前用户Long userId = UserContext.getUser();// 拼接keyLocalDateTime now = LocalDateTime.now();int dayOfMonth = now.getDayOfMonth(); // 当前为本月第几天DateTimeFormatter formatter = DateTimeFormatter.ofPattern(":yyyyMM");String yearMonth = formatter.format(now); // 年月String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId.toString() + yearMonth;// 从redis的bitMap中取出本月到当前天的记录List<Long> field = redisTemplate.opsForValue().bitField(key,BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));if (CollUtil.isEmpty(field)){ // 判空throw new BizIllegalException("查询异常");}// 返回结果List<Byte> res = new ArrayList<>();Long num = field.get(0);while (num >= 0) {if (res.size() == dayOfMonth){break;}res.add(0, (byte) (num & 1)); // 尾插法解决顺序问题
这里的意思是每次都插入到头部num = num>>>1; // num为无符号整数,所以用无符号右移}return res;}
为什么返回一个List?
综上所述,即使你只请求了一个值,Java 客户端也可能选择返回一个 List<Long>
,以确保 API 的灵活性和一致性。在你的代码中,你可以通过 field.isEmpty()
来检查列表是否为空,然后通过 field.get(0)
来获取实际的值(如果列表不为空)。
注意,这个数组填充是从后往前填充,因为num&1得到的就是最后一位,不能把最后一位放到数组最开始的位置
由于1
在二进制中表示为000...0001
(其中0
的数量取决于整数的位数,对于int
类型通常是32位或64位,但这里的关键是1
位于最右侧),因此num & 1
只关心num
的二进制表示中的最后一位。
具体来说:
- 如果
num
的二进制表示的最后一位是1
,那么num & 1
的结果也是1
(因为1 & 1 = 1
)。 - 如果
num
的二进制表示的最后一位是0
,那么num & 1
的结果是0
(因为0 & 1 = 0
)。
这种操作在编程中非常有用,特别是当你需要检查一个整数是否为奇数时。因为任何奇数的二进制表示的最后一位都是1
,而任何偶数的二进制表示的最后一位都是0
。所以,num & 1
的结果可以直接用来判断num
是奇数还是偶数。如果结果是1
,则num
是奇数;如果结果是0
,则num
是偶数。
积分
保存积分生产者
由积分规则可知,获取积分的行为多种多样,而且每一种行为都有自己的独立业务。而这些行为产生的时候需要保存一条积分明细到数据库。
我们显然不能要求其它业务的开发者在开发时帮我们新增一条积分记录,这样会导致原有业务与积分业务耦合。因此必须采用异步方式,将原有业务与积分业务解耦。
如果有必要,甚至可以将积分业务抽离,作为独立微服务。
我们需要知道:
-
userId:用户信息,必须传递
-
type:积分类型,由于不同类型通过不同的RoutingKey来发送,通过RoutingKey可以判断积分类型,无需传递
-
points:积分值,积分值也随积分方式变化,无需传递
-
createTime:时间,就是当前时间,无需传递
综上,在MQ中我们只需要传递用户id一个参数即可。
.of方法是由于上面那个注解,实现了of的构造函数
这是在签到业务下面写的
保存积分消费者
要单独创建一个controller
先写一个mq的listener,listener里有很多监听器,调用同一个serviceimpl函数,但是传递参数的业务类型不同。
查询今日积分情况
DAY8排行榜
实时排行榜业务分析
在这里给大家介绍两种不同的实现思路:
-
方案一:基于MySQL的离线排序
-
方案二:基于Redis的SortedSet
首先说方案一:简单来说,就是将数据库中的数据查询出来,在内存中自己利用算法实现排序,而后将排序得到的榜单保存到数据库中。但由于这个排序比较复杂,我们无法实时更新排行榜,而是每隔几分钟计算一次排行榜。这种方案实现起来比较复杂,而且实时性较差。不过优点是不会一直占用系统资源。
再说方案二:Redis的SortedSet底层采用了跳表的数据结构,因此可以非常高效的实现排序功能,百万用户排序轻松搞定。而且每当用户积分发生变更时,我们可以实时更新Redis中的用户积分,而SortedSet也会实时更新排名。实现起来简单、高效,实时性也非常好。缺点就是需要一直占用Redis的内存,当用户量达到数千万万时,性能有一定的下降。
当系统用户量规模达到数千万,乃至数亿时,我们可以采用分治的思想,将用户数据按照积分范围划分为多个桶,例如:
0~100分、101~200分、201~300分、301~500分、501~800分、801~1200分、1201~1500分、1501~2000分
在Redis内为每个桶创建一个SortedSet类型的key,这样就可以将数据分散,减少单个KEY的数据规模了。而要计算排名时,只需要按照范围查询出用户积分所在的桶,再累加分值比他高的桶的用户数量即可。依然非常简单、高效。
综上,我们推荐基于Redis的SortedSet来实现排行榜功能。
生成实时榜单代码
获得积分存到数据库,再写一个方法累加到redis,在我们获得积分到zset的时候,榜单就生成了,因为可以zset排序
查询指定赛季的积分排行榜以及当前用户的积分和排名信息
查询排行榜
// 3.封装int rank = from + 1;List<PointsBoard> list = new ArrayList<>(tuples.size());for (ZSetOperations.TypedTuple<String> tuple : tuples) {String userId = tuple.getValue();Double points = tuple.getScore();if (userId == null || points == null) {continue;}PointsBoard p = new PointsBoard();p.setUserId(Long.valueOf(userId));p.setPoints(points.intValue());p.setRank(rank++);list.add(p);}return list;}
每一个tuple都是一个member和score
历史积分榜
不过,这里就有一个问题需要解决:
假如有数百万用户,这就意味着每个赛季榜单都有数百万数据。随着时间推移,历史赛季越来越多,如果全部保存到一张表中,数据量会非常恐怖!
该怎么办呢?
海量数据存储策略
分区
表分区的本质是对数据的水平拆分,而拆分的方式也有多种,常见的有:
-
Range分区:按照指定字段的取值范围分区
-
List分区:按照指定字段的枚举值分区,必须提前指定好所有的分区值,如果数据找不到分区会报错
-
Hash分区:基于字段做hash运算后分区,一般做hash运算的字段都是数值类型
-
Key分区:根据指定字段的值做运算的结果分区,与hash分区类似,但不限定字段类型
对于赛季榜单来说,最合适的分区方式是基于赛季值分区,我们希望同一个赛季放到一个分区。这就只能使用List分区,而List分区却需要枚举出所有可能的分区值。但是赛季分区id是无限的,无法全部枚举,所以就非常尴尬。
分表
分表是一种表设计方案,由开发者在创建表时按照自己的业务需求拆分表。也就是说这是开发者自己对表的处理,与数据库无关。
而且,一旦做了分表,无论是逻辑上,还是物理上,就从一张表变成了多张表!增删改查的方式就发生了变化,必须自己考虑要去哪张表做数据处理。
分区则在逻辑上是同一张表,增删改查与以前没有区别。这就是分区和分表最大的一种区别。
分库和集群
这种模式的优缺点:
优点:
-
解决了海量数据存储问题,突破了单机存储瓶颈
-
提高了并发能力,突破了单机性能瓶颈
-
避免了单点故障
缺点:
-
成本非常高
-
数据聚合统计比较麻烦
-
主从同步的一致性问题
-
分布式事务问题
第二种集群:读写分离
小结
历史榜单的存储策略
天机学堂项目是一个教育类项目,用户规模并不会很高,一般在十多万到百万级别。因此最终的数据规模也并不会非常庞大。
综合之前的分析,结合天机学堂的项目情况,我们可以对榜单数据做分表,但是暂时不需要做分库和集群。
由于我们要解决的是数据过多问题,因此分表的方式选择水平分表。具体来说,就是按照赛季拆分,每一个赛季是一个独立的表
不过这里我们可以做一些简化:
-
我们可以将id作为排名,排名字段就不需要了。
-
不同赛季用不同表,那么赛季字段就不需要了。
-
不过这就存在一个问题,每个赛季要有不同的表,这些表什么时候创建呢?
显然,应该在每个赛季刚开始的时候(月初)来创建新的赛季榜单表。每个月的月初执行一个创建表的任务,我们可以利用定时任务来实现。
由于表的名称中包含赛季id,因此在定时任务中我们还要先查询赛季信息,获取赛季id,拼接得到表名,最后创建表。
- 本月的在redis所以是对上个月的数据进行存储
- 表创建完成以后就开始另一个定时任务查询redis存到数据库再清除redis数据(上个月的)
-
分页表名插件
- Markdown里有
分布式任务调度
xxl-job介绍部署
corn表达式
榜单持久化
创建表
这里只能用${}不能用#{}
jobhandler写注解里面的
持久化到mysql
这个xxl-job的corn不能写因为不知道表什么时候创建完,所以我们把这个任务作为创建表任务的子任务
清除redis缓存
它作为持久化到mysql的子任务
分片任务实现
把持久化到mysql做分片操作,但是会导致每个分片执行完以后都会调用清除redis,但是redis清除是异步操作,导致可能下一个mysql持久化分页查询查不到某一页因为被别的删除了,有没有可能分片都执行完了再执行子任务
@XxlJob("savePointsBoard2DB")
public void savePointsBoard2DB(){// 1.获取上月时间LocalDateTime time = LocalDateTime.now().minusMonths(1);// 2.计算动态表名// 2.1.查询赛季信息Integer season = seasonService.querySeasonByTime(time);// 2.2.存入ThreadLocalTableInfoContext.setInfo(POINTS_BOARD_TABLE_PREFIX + season);// 3.查询榜单数据// 3.1.拼接KEYString key = RedisConstants.POINTS_BOARD_KEY_PREFIX + time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);// 3.2.查询数据int index = XxlJobHelper.getShardIndex();int total = XxlJobHelper.getShardTotal();int pageNo = index + 1; // 起始页,就是分片序号+1int pageSize = 10;while (true) {List<PointsBoard> boardList = pointsBoardService.queryCurrentBoardList(key, pageNo, pageSize);if (CollUtils.isEmpty(boardList)) {break;}// 4.持久化到数据库// 4.1.把排名信息写入idboardList.forEach(b -> {b.setId(b.getRank().longValue());b.setRank(null);});// 4.2.持久化pointsBoardService.saveBatch(boardList);// 5.翻页,跳过N个页,N就是分片数量pageNo+=total;}TableInfoContext.remove();
}
修改redis缓存清除代码
何时用雪花算法而不是主键自增
DAY9优惠券管理
设计数据库
为什么优惠券没有用户id,兑换码有用户id,因为优惠券和用户是多对多的关系,需要用中间表
新增优惠券(管理端)
SQL报错,需要做转义,specific是关键字
@Override@Transactionalpublic void saveCoupon(CouponFormDTO dto) {// 1.保存优惠券// 1.1.转POCoupon coupon = BeanUtils.copyBean(dto, Coupon.class);// 1.2.保存save(coupon);if (!dto.getSpecific()) {// 没有范围限定return;}Long couponId = coupon.getId();// 2.保存限定范围List<Long> scopes = dto.getScopes();if (CollUtils.isEmpty(scopes)) {throw new BadRequestException("限定范围不能为空");}// 2.1.转换POList<CouponScope> list = scopes.stream().map(bizId -> new CouponScope().setBizId(bizId).setCouponId(couponId)).collect(Collectors.toList());// 2.2.保存scopeService.saveBatch(list);}
type有默认值类型是分类,只需要填补剩下两个即可
加事务注解,要么全部保存成功要么全部保存失败
保存限定范围为什么要用List
分页查询优惠券(管理端)
@Override
public PageDTO<CouponPageVO> queryCouponByPage(CouponQuery query) {Integer status = query.getStatus();String name = query.getName();Integer type = query.getType();// 1.分页查询Page<Coupon> page = lambdaQuery().eq(type != null, Coupon::getDiscountType, type).eq(status != null, Coupon::getStatus, status).like(StringUtils.isNotBlank(name), Coupon::getName, name).page(query.toMpPageDefaultSortByCreateTimeDesc());// 2.处理VOList<Coupon> records = page.getRecords();if (CollUtils.isEmpty(records)) {return PageDTO.empty(page);}List<CouponPageVO> list = BeanUtils.copyList(records, CouponPageVO.class);// 3.返回return PageDTO.of(page, list);
}
SELECT *
FROM coupon
WHERE 1=1 AND ( :type IS NULL OR discount_type = :type ) AND ( :status IS NULL OR status = :status ) AND ( :name IS NULL OR name LIKE CONCAT('%', :name, '%') )
ORDER BY create_time DESC
LIMIT :offset, :size
发放优惠券
通过有无领取开始时间来判断是立刻发放还是定时发放
前端传了{id}
@Transactional
@Override
public void beginIssue(CouponIssueFormDTO dto) {// 1.查询优惠券Coupon coupon = getById(dto.getId());if (coupon == null) {throw new BadRequestException("优惠券不存在!");}// 2.判断优惠券状态,是否是暂停或待发放if(coupon.getStatus() != CouponStatus.DRAFT && coupon.getStatus() != PAUSE){throw new BizIllegalException("优惠券状态错误!");}// 3.判断是否是立刻发放LocalDateTime issueBeginTime = dto.getIssueBeginTime();LocalDateTime now = LocalDateTime.now();boolean isBegin = issueBeginTime == null || !issueBeginTime.isAfter(now);// 4.更新优惠券// 4.1.拷贝属性到POCoupon c = BeanUtils.copyBean(dto, Coupon.class);// 4.2.更新状态if (isBegin) {c.setStatus(ISSUING);c.setIssueBeginTime(now);}else{c.setStatus(UN_ISSUE);}// 4.3.写入数据库updateById(c);// TODO 兑换码生成
}
Base32算法
兑换码生成
校验兑换码
异步生成兑换码(线程池)
面试题
在Web开发中,前端与后端之间通过HTTP请求和响应交换数据时,数据的格式和结构往往需要被精心设计以符合双方的处理逻辑和数据模型。当你提到前端发送一个三级选项的DTO(Data Transfer Object,数据传输对象)到后端,并且后端使用List<Long>
来接收scopes
这个字段时,这种设计选择背后通常有几个原因:
- 数据模型的一致性:
- 后端可能定义了一个或多个数据模型,这些模型中的
scopes
字段被设计为List<Long>
类型。这种设计可能基于业务需求,比如每个scope
代表一个特定的权限或资源标识符,而这些标识符被设计为长整型(Long)。 - 保持前后端数据模型的一致性有助于简化数据的处理和转换过程,减少潜在的错误。
- 后端可能定义了一个或多个数据模型,这些模型中的
- 灵活性:
- 使用
List<Long>
接收scopes
可以提供更大的灵活性。例如,它可以轻松地处理单个scope
(通过只包含一个元素的列表)或多个scopes
的情况,而不需要在后端代码中添加额外的逻辑来区分这两种情况。 - 这种灵活性还体现在它可以适应未来可能的变更。比如,如果将来需要添加更多的
scopes
层级或类型,使用列表作为容器可以更容易地适应这些变化。
- 使用
- 简化前端逻辑:
- 在某些情况下,前端可能更容易或更愿意以列表的形式发送数据,尤其是当这些数据来自表单或用户交互时。使用列表可以减少前端在处理数据时的复杂性,比如不需要为不同的
scopes
数量编写不同的逻辑。
- 在某些情况下,前端可能更容易或更愿意以列表的形式发送数据,尤其是当这些数据来自表单或用户交互时。使用列表可以减少前端在处理数据时的复杂性,比如不需要为不同的
- 数据验证和解析:
- 后端可以使用
List<Long>
来方便地验证和解析接收到的scopes
数据。例如,可以检查列表是否为空、是否包含重复项、是否包含无效的标识符等。 - 使用标准的Java集合类(如
List
)还可以利用Java的丰富库函数来处理数据,如排序、过滤等。
- 后端可以使用
- API设计:
- 从API设计的角度来看,使用
List<Long>
作为参数类型可以清晰地传达给API的使用者,这个参数期望接收的是一个长整型数值的列表。这种明确的类型信息有助于减少误解和错误。
- 从API设计的角度来看,使用
综上所述,使用List<Long>
来接收前端发送的scopes
数据是基于多种因素的考虑,包括数据模型的一致性、灵活性、简化前端逻辑、数据验证和解析的便利性,以及API设计的清晰性。当然,具体的设计选择还需要根据实际的业务需求和技术栈来决定。
DAY10领取优惠券
查询发放中的优惠券
因此,我们应该在返回值中标示优惠券的这些状态:
-
是否可以领取:也就是优惠券还有剩余并且用户已领取数量未超过限领数量。如果为false,展示为已抢完
-
是否已经领取:也就是用户是否有已经领取,尚未使用的券。如果有,则显示为去使用
如果以上都不成立,则展示为立即领取
@Override
public List<CouponVO> queryIssuingCoupons() {// 1.查询发放中的优惠券列表List<Coupon> coupons = lambdaQuery().eq(Coupon::getStatus, ISSUING).eq(Coupon::getObtainWay, ObtainType.PUBLIC).list();if (CollUtils.isEmpty(coupons)) {return CollUtils.emptyList();}// 2.统计当前用户已经领取的优惠券的信息List<Long> couponIds = coupons.stream().map(Coupon::getId).collect(Collectors.toList());// 2.1.查询当前用户已经领取的优惠券的数据List<UserCoupon> userCoupons = userCouponService.lambdaQuery().eq(UserCoupon::getUserId, UserContext.getUser()).in(UserCoupon::getCouponId, couponIds).list();// 2.2.统计当前用户对优惠券的已经领取数量Map<Long, Long> issuedMap = userCoupons.stream().collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));// 2.3.统计当前用户对优惠券的已经领取并且未使用的数量Map<Long, Long> unusedMap = userCoupons.stream().filter(uc -> uc.getStatus() == UserCouponStatus.UNUSED).collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));// 3.封装VO结果List<CouponVO> list = new ArrayList<>(coupons.size());for (Coupon c : coupons) {// 3.1.拷贝PO属性到VOCouponVO vo = BeanUtils.copyBean(c, CouponVO.class);list.add(vo);// 3.2.是否可以领取:已经被领取的数量 < 优惠券总数量 && 当前用户已经领取的数量 < 每人限领数量vo.setAvailable(c.getIssueNum() < c.getTotalNum()&& issuedMap.getOrDefault(c.getId(), 0L) < c.getUserLimit());// 3.3.是否可以使用:当前用户已经领取并且未使用的优惠券数量 > 0vo.setReceived(unusedMap.getOrDefault(c.getId(), 0L) > 0);}return list;
}
在Java中,你提供的代码片段试图使用Stream API来收集userCoupons
(假设它是一个包含UserCoupon
对象的集合)中每个couponId
的出现次数,但代码的实现与预期结果不匹配。你的目标是创建一个Map<Long, Long>
,其中键是couponId
,值是对应的出现次数。然而,你使用的Collectors.counting()
方法会返回一个Long
值,这个值表示每个组的元素数量,但你的代码实际上返回了一个Map<Long, Long>
的映射,但映射的值是通过Collectors.counting()
得到的,这本身是正确的。
领取优惠券
post请求,用户一点击立即领取就传优惠券id。
构造方法注入会出现循环依赖,但是如果用set注入,spring已经解决循环依赖问题(三级缓存)
所以我们注入mapper
private final CouponMapper couponMapper;
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {private final CouponMapper couponMapper;@Override@Transactionalpublic void receiveCoupon(Long couponId) {// 1.查询优惠券Coupon coupon = couponMapper.selectById(couponId);if (coupon == null) {throw new BadRequestException("优惠券不存在");}// 2.校验发放时间LocalDateTime now = LocalDateTime.now();if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {throw new BadRequestException("优惠券发放已经结束或尚未开始");}// 3.校验库存if (coupon.getIssueNum() >= coupon.getTotalNum()) {throw new BadRequestException("优惠券库存不足");}Long userId = UserContext.getUser();// 4.校验每人限领数量// 4.1.统计当前用户对当前优惠券的已经领取的数量Integer count = lambdaQuery().eq(UserCoupon::getUserId(), userId).eq(UserCoupon::getCouponId(), couponId).count();// 4.2.校验限领数量if(count != null && count >= coupon.getUserLimit()){throw new BadRequestException("超出领取数量");}// 5.更新优惠券的已经发放的数量 + 1couponMapper.incrIssueNum(coupon.getId());// 6.新增一个用户券saveUserCoupon(coupon, userId);}private void saveUserCoupon(Coupon coupon, Long userId) {// 1.基本信息UserCoupon uc = new UserCoupon();uc.setUserId(userId);uc.setCouponId(coupon.getId());// 2.有效期信息LocalDateTime termBeginTime = coupon.getTermBeginTime();LocalDateTime termEndTime = coupon.getTermEndTime();if (termBeginTime == null) {termBeginTime = LocalDateTime.now();termEndTime = termBeginTime.plusDays(coupon.getTermDays());}uc.setTermBeginTime(termBeginTime);uc.setTermEndTime(termEndTime);// 3.保存save(uc);}
}
public interface CouponMapper extends BaseMapper<Coupon> {@Update("UPDATE coupon SET issue_num = issue_num + 1 WHERE id = #{couponId}")int incrIssueNum(@Param("couponId") Long couponId);
}
兑换码兑换优惠券
登陆拦截放行问题
发放中的优惠券,不管登录还是未登录都应该可以查看。但目前,未登录情况下访问优惠券页面就会报错
超卖问题
乐观锁解决超卖问题
关键在数据库的行锁
注:若是不抛出异常,不会触发事务回滚,可能会导致issue_num
为100,但user_coupon
表中有100+条记录
锁失效问题
锁的不是同一个对象,因为new了
还是锁不住
或者直接将方法声明为synchronized
同步方法,这样也能解决锁失效问题;
但这个方法缺点在于,对于所有用户请求都会加锁
,这样会比较影响性能;
锁失效问题解决(事务和锁边界问题)
注意,这里是先开启事务,再获取锁;而业务执行完毕后,是先释放锁,再提交事务。
假如用户限领数量为1,当前用户没有领过券。但是这个人写了一个抢券程序,用自己的账号并发的来访问我们。
假设此时有两个线程并行执行这段逻辑:
-
线程1开启事务,然后获取锁成功;线程2开启事务,但是获取锁失败,被阻塞
-
线程1执行业务,由于没领过,所有业务都能正常执行,不再赘述
-
线程1释放锁。此时线程2立刻获取锁成功,开始执行业务:
-
线程2统计用户已领取数量。由于线程1尚未提交事务,此时线程2读取不到未提交数据。因此认为当前用户没有领券。
-
判断限领数量通过,于是也新增一条券
-
安全问题发生了!
-
总结:由于锁过早释放,导致了事务尚未提交,判断出现错误,最终导致并发安全问题发生。
这其实就是事务边界和锁边界的问题。
由于事务方法需要public修饰,并且被spring管理。因此要把事务方法向上抽取到service接口中
事务失效问题
Spring代理对象执行事务方法时不会出现事务失效问题,主要基于以下几个关键原理和机制:
1. 代理模式
Spring使用代理模式(Proxy Pattern)来实现事务管理。当一个类被声明为@Transactional
时,Spring在运行时会自动为该类生成一个代理对象。这个代理对象会在调用目标方法时,拦截方法调用,并在方法执行前后插入事务管理的逻辑,如开启事务、提交事务或回滚事务。通过这种方式,事务管理的逻辑与业务逻辑被有效地分离,使得业务代码更加专注于业务逻辑的实现。
2. AOP(面向切面编程)
Spring通过AOP技术将事务管理的代码与业务逻辑代码分离开来。AOP允许开发者在不影响业务逻辑代码的情况下,增加额外的行为(如事务管理)。在Spring中,事务管理被视为一个切面(Aspect),它会被织入(Weave)到被@Transactional
注解标记的方法中。通过这种方式,事务管理的逻辑能够自动地应用到需要事务支持的方法上,而无需在业务代码中显式地编写事务管理的代码。
3. 事务管理器
Spring提供了事务管理器(Transaction Manager)来统一管理事务。事务管理器负责开启、提交和回滚事务,并管理事务的隔离级别和传播行为等。当代理对象拦截到方法调用时,它会根据@Transactional
注解的配置信息(如传播行为、隔离级别等)来与事务管理器进行交互,以执行相应的事务管理操作。
4. 代理对象的作用
代理对象在事务管理中起到了至关重要的作用。由于代理对象会拦截方法调用,并在方法执行前后插入事务管理的逻辑,因此只有当通过代理对象调用方法时,事务管理的逻辑才会被触发。如果直接调用目标对象的方法(即未通过代理对象),那么事务管理的逻辑将不会被执行,从而导致事务失效。
5. 代理对象的生成和调用
在Spring中,代理对象的生成和调用通常是自动完成的。当Spring容器启动时,它会扫描被@Transactional
注解标记的类,并为这些类生成代理对象。在运行时,当其他组件(如控制器、服务等)通过依赖注入(DI)获取这些类的实例时,实际上获取到的是代理对象的实例。因此,只要确保在调用事务方法时是通过Spring容器管理的代理对象进行的,就可以避免事务失效的问题。
综上所述,Spring代理对象执行事务方法时不会出现事务失效问题,主要是因为代理模式、AOP技术和事务管理器的共同作用。通过代理对象拦截方法调用并在方法执行前后插入事务管理的逻辑,Spring能够确保事务管理的逻辑被正确地执行,从而保障数据的一致性和完整性。
解决项目事务失效
面试题
DAY11
分布式锁
集群情况下syconized锁不住,要分布式锁
集群下的锁失效问题
Redisson
异步领券
分析
异步,节省内存
REDIS结构两个hash
优惠券缓存
key加一个map结构
删除缓存要定时任务
mq生产者(写在发优惠券)
mq消费者(直接编写监听器)
修改领券代码(修改分布式锁位置)
因为查询操作需要使用redis,整体需要原子性,所以修改分布式锁的位置
解锁在finally里
DAY12优惠券使用规则
优惠券定义
预下单生成订单id是为了避免重复下单
数据库里只要是钱全部存分,怕精度丢失
所以我们要写四个接口和对四种类型的优惠券单独写四个java类来实现
优惠券智能推荐
查询用户券并初步筛选
查用户券表,根据里面的优惠券id查优惠券信息表,多表联查
用户券id=优惠券id,然后把优惠券信息查出来
初步筛选是总价高于优惠券
List<Coupon> availableCoupons = coupons.stream()
.filter(c -> DiscountStrategy.getDiscount(c.getDiscountType()).canUse(totalAmount, c))
.collect(Collectors.toList());
细筛
每个优惠券的都有自己的使用范围(指定的课程分类),而订单中的课程也会有不同的分类,因此每张优惠券可以使用的课程可能不同。
我们之前在初筛时,是基于所有课程计算总价,判断优惠券是否可用,这显然是不合适的。应该找出优惠券限定范围内的课程,然后计算总价,判断是否可用。
因此,细筛步骤有两步:
-
首先要基于优惠券的限定范围对课程筛选,找出可用课程。如果没有可用课程,则优惠券不可用。
-
然后对可用课程计算总价,判断是否达到优惠门槛,没有达到门槛则优惠券不可用
可以发现,细筛需要查询每一张优惠券的限定范围,找出可用课程。这就需要查询coupon_scope
表,还是比较麻烦的。而且,后期计算优惠明细的时候我们还需要知道每张优惠券的可用课程,因此在细筛完成后,建议把每个优惠券及对应的可用课程缓存到一个Map
中,形成映射关系,避免后期重复查找。