路径参数映射到实体类,传的json数据映射为实体类需要加注解
如果不写的话,可能前端查询的内容为空,不写,但是浏览器报400错误,请求的路径和数据不匹配,
查询课程名称、发布状态等信息,mapper层用mybatis-plus框架lambadaQueryWrapper
bybatis分页插件的原理:
首先分页的参数会先传到Threadlocal中,拦截器拦截sql语句并对sql语句加一些东西,首先会从Threadlocal中取出分页参数,然后sql语句在最后会添加limit对查到的数据进行限制。
查询树型表的方法?
- 自链接将id与表的parentId联系起来,
- 使用mysql的递归查询
查到的一般都是我们具体的分类好的这样一个个记录组成的list,返回给前端的话是需要重新组织数据的,比如说是有两个分类的话,我们需要吧第一个分类的信息里面其实就是对应的DTO类里面呢加一个list属性,list中要存这些二级分类的具体信息,这块涉及到从mapper中取到数据在service中重新组织再返回给前端。
mybatis的ResultType和resultMap的区别?
ResultType只要表中的字段名和po类的属性名对应就可以。
resultMap表中的字段名和po类的属性名不对应,需要映射。
#{} 和${}有什么区别?
#{}是一个占位符,可以防止sql注入
${}用在动态sql中拼接字符串,可能导致sql注入。
系统如何处理异常?
定义一个统一的异常处理器去捕获并处理异常,使用控制器增强注解@ControllerAdvice和异常处理注解@ExceptionHandler来实现。
- 处理自定义异常:我们在程序的校验结果中主动抛出自定义的异常对象,抛出异常时指定详细的异常信息,异常处理器捕获异常信息记录异常日志并响应给用户。
- 处理位置异常:接口执行过程中的一些运行时异常也会由异常处理器统一捕获,记录异常日志,统一响应给用户500错误,在异常处理器中还可以针对某个异常类型进行单独处理。
对表单数据的校验怎么实现的?
JSR303校验,它针对的是controller中我们拿到的参数的校验,前端表单传过来的参数可能不符合逻辑,对表单数据进行校验,在参数的po类的属性上添加注解实现对象表单数据的校验,当不同业务时可能表单的校验逻辑不一样,对这些po类的属性分组,实现新增、修改不同场景下的表单数据校验。
课程计划分为两级:大章节和小章节:用到了树形表,分为两级查询,第一级是章节,第二节是章节后面的小节,在小节中又有关联的视频,这个在另一张表中,用左外连接,小节的信息有,但是其对应的视频可有可无
继承了Teachplan有了基本信息后,又因为表是树形结果,加了list,又因为小结关联了视频,加了teachplanMedia属性
要区别一对一映射association,一对多隐射用collection
我们在课程管理那里,比如我们输入了一个课程的名称,要求显示和这个课程有关的计划(上面一个,这个课程有几章、每章下面几小结):
用到了上面的树型结构,我们将章节与小节包括小节的视频先查出来,用到了自链接和外链接,这三部分都会记录在一条记录中,当然有很多记录,我们筛选的条件就是吧章节选出来,和我们传入的课程id,通过将查到的数据进行映射,映射到具体的字段,将整合的数据返回给前端页面。
bug:在修改了课程计划后,我们新增了章节,数据库中有我们新增的章节但是在页面上没有显示,原因是我们sql语句问题,自链接和左外链接的区别,我们将第一个表与第二个表进行自链接,以id与parentid相等为条件,自链接就导致了当我们新增章节但是此时章节下面还没有小节,这时自链接只是把左表和右表满足连接条件的数据查出来,其它的数据都没有,查出来的是一条条的记录这个记录中有章节信息和这个章节对应的子节信息,但是,子节现在还没有,导致我们查出来的数据一条也没有!!!
改bug:将左表(章节)与右表(子节)左外链接,左外连接:左表和右表满足条件的数据,和左表中不满足条件的数据!!!
在nacos配置了dev环境,但是我们在实际开发中一个服务要启多个服务(端口),各配置文件 的优先级:项目应用名配置文件 > 扩展配置文件 > 共享配置文件 > 本地配置文件,我们一般的做法是这样::
问题:目前是在uploadFile方法上添加@Transactional,当调用uploadFile方法前会开启数据库事务,如果上传文件过程时间较长那么数据库的事务持续时间就会变长,这样数据库链接释放就慢,最终导致数据库链接不够用
非事务方法调用了事务方法要用当前代理 对象调用。
如何解决呢?通过代理对象去调用addMediaFilesToDb方法即可解决。
- 在MediaFileService的实现类中注入MediaFileService的代理对象
- 将addMediaFilesToDb方法提成接口
- 调用addMediaFilesToDb方法的代码处改为如下
断点续传是怎么做的?分块文件处理?
断点续传逻辑:大视频文件上传前先检测minio中有没有,根据前端传过来的md5值来找数据库,media_files这个表的id就是文件的md5,看看有没有,没有就再查分块,也是发一个请求,然后根据md5找到文件在minio存的地方,看看当前查询 的分块在不在,这个是前端传过来的分块的索引,要是这个分块不存在,再上传分块文件,最后再合并全部分块,会验证合并的和前端传过来的md5是不是一致来确保文件没有出错,最后将分块删除并吧合并后的文件信息写入数据库,入库这里要加事务控制,又为避免资源耗费,不在整个逻辑上面加事务,只在入库这个方法这里加事务,非事务方法调用事务方法:注入当前实现类的接口,让代理对象调用。避免事务失效。
视频上传成功后需要对视频进行转码处理:通过ffmpeg对视频转码
对一个视频的转码可以理解为一个任务的执行,如果视频的数量比较多
,如何去高效处理一批任务呢----->分布式任务调度
场景
- 每隔24小时执行数据备份任务。
- 12306网站会根据车次不同,设置几个时间点分批次放票。
- 某财务系统需要在每天上午10点前结算前一天的账单数据,统计汇总。
- 商品成功发货后,需要向客户发送短信提醒
- 设置每月第一天凌晨1点执行任务
- 优惠卷服务中包括了定时发放优惠卷的调度程序
什么是分布式任务调度?优惠卷服务中包括了定时发放优惠卷的调度程序,结算服务中包括了定期生成报表的任务调度程序,由于采用分布式架构,一个服务往往会部署多个冗余实例来运行我们的业务,在这种分布式系统环境下运行任务调度,我们称之为分布式任务调度,如下图
XXL-JOB主要有调度中心、执行器、任务。
执行流程:
1.任务执行器根据配置的调度中心的地址,自动注册到调度中心
2.达到任务触发条件,调度中心下发
任务
3.执行器基于线程池执行任务,并把执行结果放入内存队列中、把执行日志写入日志文件中
4.执行器消费内存队列中的执行结果,主动上报给调度中心
5.当用户在调度中心查看任务日志,调度中心请求任务执行器,任务执行器读取任务日志文件并返回日志详情
分布式任务处理:分片广播策略
分片是指调度中心以执行器为维度进行分片,将集群中的执行器标上序号
:0,1,2,3…,广播
是指每次调度会向集群中的所有执行器发送任务调度,请求中携带分片参数,但是要注意,在分布式下,给所有的执行器下发任务,图中的3,为了避免3号任务不重复,需要采用分布式锁。
通过作业分片方案保证了执行器之间查询到不重复的任务,如果一个执行器在处理一个视频还没有完成,此时调度中心又一次请求调度,为了不重复处理同一个视频该怎么办?
- 首先配置调度过期策略:调度中心错过调度时间的补偿处理策略,包括:忽略、立即补偿触一次,选择忽略
- 阻塞处理策略:
- 单机串行(默认):调度请求进入单机执行器后,调度请求进入FIFO队列并以串行方式运行;
- 丢弃后续调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败
只做这些配置可以保证任务不会重复执行吗?
做不到,还需要保证任务处理的幂等性,什么是任务的幂等性?任务的幂等性
是指:对于数据的操作不论多少次,操作的结果始终是一致的。在本项目中要实现的是不论多少次任务调度同一个视频只执行一次成功的转码
。
什么是幂等性?
它描述了一次和多次请求某一个资源对于资源本身应该具有同样
的结果。
幂等性是为了解决重复提交
问题,比如:恶意刷单
,重复支付
等。
解决幂等性常用的方案:
1)数据库约束
,比如:唯一索引,主键。
2)乐观锁
,常用于数据库,更新数据时根据乐观锁状态去更新。
3)唯一序列号
,操作传递一个唯一序列号,操作时判断与该序列号相等则执行。
基于以上分析,在执行器接收调度请求去执行视频处理任务时要实现视频处理的幂等性,要有办法去判断该视频是否处理完成,如果正在处理中或处理完则不再处理。这里我们在数据库视频处理表中添加处理状态字段,视频处理完成更新状态为完成,执行视频处理前判断状态是否完成,如果完成则不再处理。
视频处理的详细流程如下:
1、任务调度中心广播作业分片(一个作业就表示上传完一个视频,需要转码)。
2、执行器收到广播作业分片,从数据库读取待处理任务
,读取未处理及处理失败的任务。
3、执行器更新
任务为处理中,根据任务内容从MinIO下载要处理的文件。(乐观锁)
4、执行器启动多线程去处理任务。
5、任务处理完成,上传处理后的视频到MinIO。
6、将更新
任务处理结果,如果视频处理完成除了更新任务处理结果以外还要将文件的访问地址更新至文件表
中,最后将任务完成记录写入历史表
,将待处理任务表中相关记录删除。
视频处理代码逻辑(口述):用户将大文件比如视频上传后(分块、断点续传),只要上传成功了就将这个文件信息(视频)写到待处理任务表中,采用xxl-job框架,执行器从待处理任务表中取它所对应的任务,这里是根据分片的总数和序号来对应执行器所对应的任务的,分片总数的化就是有多少个执行器,分片序号的话就是当前是第几个执行器,这样做的目的就是避免执行器拿到的任务重复,但是如果多个执行器分布式部署
,并不能保证同一个视频只有一个执行器去处理,这里用到分布式锁,本项目采用数据库实现分布式锁:多个线程同时去更新相同的记录,谁更新成功谁就抢到锁,拿到锁就执行器对应执行任务,转码完成后更新任务处理结果,更新视频的URL、及任务处理结果,将待处理任务记录删除,同时向历史任务表添加记录,这样做目的是待处理任务表越来越多,查询效率就变慢了,所以任务处理完就删除,写到任务历史表中。
视频处理思路逻辑(口述):用户传了视频的话会存在minio分布式文件系统中去,可能有很多格式的视频,视频处理就是将用户传的视频转码为固定格式,视频采用并发处理,每个视频使用一个线程去处理,每次处理的视频数量不要超过cpu核心数。
- 首先获取了执行器总数以及第几个执行器,然后在待处理任务表中取出任务,将任务加入线程池中,然后就是开启任务(分布式锁基于数据库乐观锁实现),在minio中查到视频然后下载到本地,用ttmpeg转码,再上传到minio,处理了一个任务countDownLatch减1,当都处理完任务了线程也就不阻塞了。
分布式锁:
synchronized只能保证同一个虚拟机中多个线程去争抢锁。
如果是多个执行器分布式部署,并不能保证同一个视频只有一个执行器去处理。
现在要实现分布式环境下所有虚拟机中的线程去同步执行就需要让多个虚拟机去共用一个锁,虚拟机可以分布式部署,锁也可以分布式部署,如下图:
虚拟机都去抢占同一个锁,锁是一个单独的程序提供加锁、解锁服务。
该锁已不属于某个虚拟机,而是分布式部署,由多个虚拟机所共享,这种锁叫分布式锁
实现分布式锁的方法:
1、基于数据库实现分布锁
利用数据库主键唯一性的特点,或利用数据库唯一索引、行级锁的特点,多个线程同时去更新相同的记录,谁更新成功谁就抢到锁。
2、基于redis实现锁
redis提供了分布式锁的实现方案,比如:SETNX、set nx、redisson等。
拿SETNX举例说明,SETNX命令的工作过程是去set一个不存在的key,多个线程去设置同一个key只会有一个线程设置成功,设置成功的的线程拿到锁。
3、使用zookeeper实现
zookeeper是一个分布式协调服务,主要解决分布式程序之间的同步的问题。zookeeper的结构类似的文件目录,多线程向zookeeper创建一个子目录(节点)只会有一个创建成功,利用此特点可以实现分布式锁,谁创建该结点成功谁就获得锁。
多个执行器对视频进行转码为特定格式,比如说现在有两个执行器,有四个任务(4个视频需要解码),执行器1对任务0和2转码,执行器2对任务1和3转码,现在是两个执行器任务也少,但当很多时,为了避免多个执行器对同一个视频转码,一个视频转码只需要转码一次就ok,就需要用到分布式锁 。
- 基于数据库实现(乐观锁):其实乐观锁核心就是where条件过滤,所有人都可以执行sql,但是只有一个人可以成功,然后它改掉version后其余的就进不来了。
update media_process m set m.status='4' where (m.status='1' or m.status='3') and m.fail_count<3 and m.id=?
加版本号:
update t1 set t1.data1='', t1.version='2' where t1.version='1'
扩展:synchronized是一种悲观锁,在执行被synchronized包裹的代码时需要首先获取锁,没有拿到锁则无法执行,是总悲观的认为别的线程会去抢,所以叫悲观锁
乐观锁的思想是它不认为会有线程去争抢,尽管去执行,如果没有执行成功就再去重试
为什么选用XXL-JOB不用mq???
答:xxl-job的话确实是有些局限性的,比如说从本项目出发的话,我们的目的其实就是对视频进行转码,那一个视频只需要一次转码就可以了,执行器是采用这种分布式的,其实就是让处理视频转码的速度快些,那么xxl-job是分片广播给每个执行器的,执行器拿到任务的话看看这个任务是不是我这个执行器处理,这个的解决办法就是用分布式锁,采用了这种基于数据库的类似乐观锁来实现的,那这样就保证了一个任务只由一个执行器处理,对比mq的话,mq人家路由就灵活的多,我们可以用routekey来确保交给指定的队列,也可以广播可以指定路由,所以mq的话其实更灵活,但xxl-job其实也有优点,虽然它分片广播,然后取任务的话我们需要去在数据库去查然后设计谁来处理,虽然麻烦点还需要访问数据库,但是xxl的定时策略其实很多样,虽然mq也可以实现这种延时队列啊这种,但相比较要麻烦一些。
多线程处理视频任务的思考:
任务补偿机制:
如果有线程抢占了某个视频的处理任务,如果线程处理过程中挂掉了,该视频的状态将会一直是处理中,其它线程将无法处理,这个问题需要用补偿机制。
单独启动一个任务找到待处理任务表中超过执行期限但仍在处理中的任务,将任务的状态改为执行失败。
达到最大失败次数:
当任务达到最大失败次数时一般就说明程序处理此视频存在问题,这种情况就需要人工处理,在页面上会提示失败的信息,人工可手动执行该视频进行处理,或通过其它转码工具进行视频转码,转码后直接上传mp4视频
分块文件清理问题:
上传一个文件进行分块上传,上传一半不传了,之前上传到minio的分块文件要清理吗?怎么做的?
1、在数据库中有一张文件表记录minio中存储的文件信息。
2、文件开始上传时会写入文件表,状态为上传中,上传完成会更新状态为上传完成。
3、当一个文件传了一半不再上传了说明该文件没有上传完成,会有定时任务去查询文件表中的记录,如果文件未上传完成则删除minio中没有上传成功的文件目录。
教学机构确认课程内容无误,提交审核,平台运营人员对课程内容审核,审核通过后教学机构人员发布课程成功。
课程发布模块共包括三块功能:
1、课程预览
2、课程审核
3、课程发布
课程预览: 课程预览就是把课程的相关信息进行整合,在课程详情界面进行展示。
1、点击课程预览,通过Nginx、后台服务网关请求内容管理服务进行课程预览。
2、内容管理服务查询课程相关信息进行整合,并通过模板引擎技术在服务端渲染生成页面,返回给浏览器。
3、通过课程预览页面点击”马上学习“打开视频播放页面。
4、视频播放页面通过Nginx请求后台服务网关,查询课程信息展示课程计划目录,请求媒资服务查询课程计划绑定的视频文件地址,在线浏览播放视频。
课程审核:
如何控制课程审核通过才可以发布课程呢?
- 在课程基本表course_base表设置课程审核状态字段,包括:未提交、已提交(未审核)、审核通过、审核不通过。
问题:比如:运营人员正在审核时教学机构把数据修改了。
- 为了解决这个问题,专门设计课程预发布表。
课程提交后,会将四表的信息写到预发布表中,并将预发布表的状态写为已提交,运营人员审核时会从预发布表中取到课程的信息(状态为已提交的),审核通过后会将预发布表状态变为审核通过可以发布,同时更新课程基本信息表的审核状态(为了方便后续判断直接从课程表中取状态就ok),这时会将预发布表的信息写到课程发布表中,引入预发布表是为了在审核时机构人员也可以改,改了后再提交,运营人员再审核,避免了审核中机构人员同时进行修改。
课程发布:
在网站上展示课程信息需要解决课程信息显示的性能问题,如果速度慢(排除网速)会影响用户的体验性。
如何去快速搜索课程?es
打开课程详情页面仍然去查询数据库可行吗?no
1、向内容管理数据库的课程发布表存储课程发布信息
,更新课程基本信息表
中发布状态为已发布。
2、向Redis存储课程缓存信息
。
3、向Elasticsearch存储课程索引信息
。
4、请求分布文件系统存储课程静态化页面
(即html页面),实现快速浏览课程详情页面。
redis中的课程缓存信息是将课程发布表中的数据转为json进行存储。
elasticsearch中的课程索引信息是根据搜索需要将课程名称
、课程介绍
等信息进行索引存储
。
MinIO中存储了课程的静态化页面文件(html网页),查看课程详情是通过文件系统
去浏览课程详情页面
什么是本地事务?
平常我们在程序中通过spring去控制事务是利用数据库本身的事务特性来实现的,因此叫数据库事务,就是操作同一个数据库的几张表,加@transtion注解就ok
什么是分布式事务?
现在的需求是课程发布操作后将数据写入数据库、redis、elasticsearch、MinIO四个地方,这四个地方已经不限制在一个数据库内,是由四个分散的服务
去提供,与这四个服务去通信需要网络通信
,而网络存在不可到达性,这种分布式系统环境下,通过与不同的服务进行网络通信
去完成事务称之为分布式事务
场景:例如用户注册送积分,银行转账,创建订单减库存,这些都是分布式事务。
下边的场景都会产生分布式事务:(两个连接调用)
微服务架构下:
多服务单数据库:
基于项目选择:
- 使用消息队列通知的方式去实现,通知
失败自动重试
,达到最大失败次数需要人工处理 - 使用任务调度的方案,启动任务调度将课程信息由数据库同步到elasticsearch、MinIO、redis中。
消息表+任务调度实现:
1、执行发布操作,内容管理服务存储课程发布表的同时向消息表添加一条“课程发布任务”。这里使用本地事务保证课程发布信息保存成功,同时消息表也保存成功。
2、任务调度服务定时调度内容管理服务扫描消息表,由于课程发布操作后向消息表插入一条课程发布任务,此时扫描到一条任务。
3、拿到任务开始执行任务,分别向redis、elasticsearch及文件系统存储数据。
4、任务完成后删除消息表记录。
如何保证任务的幂等性?
任务执行完成后会从消息表删除,如果消息的状态是完成或不存在消息表中则不用执行。
如何保证任务不重复执行?
这里是信息同步类任务,即使任务重复执行也没有关系
课程发布任务需要执行三个同步操作:存储课程到redis、存储课程到索引库,存储课程页面到文件系统。如果其中一个小任务已经完成也不应该去重复执行。这里该如何设计?
将小任务作为任务的不同的阶段,在消息表中设计阶段状态。
页面静态化:
预览功能通过freemarker在页面模板中填充数据,生成html页面,这个过程是当客户端请求服务器时服务器才开始渲染生成html页面,最后响应给浏览器,服务端渲染的并发能力是有限的
页面静态化则强调将生成html页面的过程提前,提前使用模板引擎技术生成html页面,当客户端请求时直接请求html页面,静态页面可以使用nginx
什么时候能用页面静态化技术?
- 当数据变化不频繁,一旦生成静态页面很长一段时间内很少变化,此时可以使用页面静态化。
- 根据课程发布的业务需求,课程发布后修改频度不大,所以适合使用页面静态化
feign远程调用:
静态化生成文件后需要上传至分布式文件系统,根据微服务的职责划分,媒资管理服务负责维护文件系统中的文件,所以内容管理服务对页面静态化生成html文件需要调用媒资管理服务的上传文件接口。
微服务的雪崩效应:服务B调用服务A,由于A服务异常导致B服务响应缓慢,最后B、C等服务都不可用,像这样由一个服务所引起的一连串的多个服务无法提供服务即是微服务的雪崩效应
解决:可以采用熔断、降级的方法去解决。
熔断:
当下游服务异常而断开与上游服务的交互,从而保证上游服务不受影响
降级:
当下游服务异常触发熔断后,上游服务就不再去调用异常的微服务而是执行了降级处理逻辑,这个降级处理逻辑可以是本地一个单独的方法。
两者都是为了保护系统,熔断是当下游服务异常时一种保护系统的手段,降级是熔断后上游服务处理熔断的方法
- 在FeignClient中指定fallbackFactory
@FeignClient(value = "media-api",configuration = MultipartSupportConfig.class,fallbackFactory = MediaServiceClientFallbackFactory.class)
- 定义MediaServiceClientFallbackFactory如下:
@Slf4j
@Component
public class MediaServiceClientFallbackFactory implements FallbackFactory<MediaServiceClient> {
@Override
public MediaServiceClient create(Throwable throwable) {
return new MediaServiceClient(){
@Override
public String uploadFile(MultipartFile upload, String objectName) {
//降级方法
log.debug("调用媒资管理服务上传文件时发生熔断,异常信息:{}",throwable.toString(),throwable);
return null;
}
};
}
}
降级处理逻辑:
返回一个null对象
,上游服务请求接口得到一个null说明执行了降级处理
。
Spring Security的执行流程如下:
- 用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
- 然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证
- 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。
- SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。
- 可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个List列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication。
Oauth2认证的例子,网站使用微信认证扫码登录的过程:
OAuth2在本项目的应用:
1、学成在线访问第三方系统的资源。
本项目要接入微信扫码登录所以本项目要使用OAuth2协议访问微信中的用户信息。
2、外部系统访问学成在线的资源 。
同样当第三方系统想要访问学成在线网站的资源也可以基于OAuth2协议。
3、学成在线前端(客户端) 访问学成在线微服务的资源。
本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议进行认证。
OAuth2的授权模式:
Spring Security支持OAuth2认证,OAuth2提供授权码模式、密码模式(常用的两种),微信扫码登录的例子就是基于授权码模式
OAuth2的几个授权模式是根据不同的应用场景以不同的方式去获取令牌,最终目的是要获取认证服务颁发的令牌,最终通过令牌去获取资源。
授权码模式简单理解是使用授权码去获取令牌,要想获取令牌先要获取授权码,授权码的获取需要资源拥有者亲自授权同意才可以获取。
授权码模式:
1、用户打开浏览器。
2、通过浏览器访问客户端即黑马网站。
3、用户通过浏览器向认证服务请求授权,请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址。
4、认证服务向资源拥有者返回授权页面。
5、资源拥有者亲自授权同意。
6、通过浏览器向认证服务发送授权同意。
7、认证服务向客户端地址重定向并携带授权码。
8、客户端即黑马网站收到授权码。
9、客户端携带授权码向认证服务申请令牌。
10、认证服务向客户端颁发令牌。
密码模式:
1、资源拥有者提供账号和密码
2、客户端向认证服务申请令牌,请求中携带账号和密码
3、认证服务校验账号和密码正确颁发令牌。
这种模式十分简单,但是却意味着直接将用户敏感信息泄漏给了client,因此这就说明这种模式只能用于client是我们自己开发的情况下。
本项目中:授权码模式适合客户端和认证服务非同一个系统的情况,所以本项目使用授权码模式完成微信扫码认证,采用密码模式作为前端请求微服务的认证方式。
JWT:
客户端申请到令牌,接下来客户端携带令牌去访问资源,到资源服务器将会校验令牌的合法性。资源服务器如何校验令牌的合法性?
以密码模式为例:
问题
:校验令牌需要远程请求认证服务,客户端的每次访问都会远程校验,执行性能低。
解决办法:令牌采用JWT格式即可解决上边的问题,用户认证通过后会得到一个JWT令牌
,JWT令牌中已经包括了用户相关的信息
,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验
,无需每次都请求认证服务完成授权
使用JWT可以实现无状态认证,什么是无状态认证?
传统的基于session的方式是有状态认证,用户登录成功将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用。
基于令牌技术在分布式系统中实现认证则服务端不用存储session,可以将用户身份信息存储在令牌中,用户认证通过后认证服务颁发令牌给用户,用户将令牌存储在客户端,去访问应用服务时携带令牌去访问,服务端从jwt解析出用户信息
JWT令牌的优点:
1、jwt基于json,非常方便解析。
2、可以在令牌中自定义丰富的内容,易扩展。
3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
4、资源服务使用JWT可不依赖认证服务即可完成授权。
为什么JWT可以防止篡改?
签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容那么服务器验证签名就会失败,要想保证验证签名正确必须保证内容、密钥与签名前一致。
从上图可以看出认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造jwt令牌。
JWT还可以使用非对称加密,认证服务自己保留私钥
,将公钥下发给受信任的客户端资源服务
,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密和解密,非对称加密效率低但相比对称加密非对称加密更安全一些。
jwt令牌中记录了用户身份信息,当客户端携带jwt访问资源服务,资源服务验签通过后将前两部分的内容还原即可取出用户的身份信息,并将用户身份信息放在了SecurityContextHolder上下文(容器),SecurityContext与当前线程进行绑定,方便获取用户身份
网关认证:
网关的职责:
- 1、网站白名单维护
- 2、认证:校验jwt的合法性。
用户认证 :
用户提交账号和密码由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。
我们只要实现UserDetailsService 接口查询数据库得到用户信息返回UserDetails 类型的用户信息即可
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
XcUserMapper xcUserMapper;
/**
* @description 根据账号查询用户信息
* @param s 账号
* @return org.springframework.security.core.userdetails.UserDetails
* @author Mr.M
* @date 2022/9/28 18:30
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, s));
if(user==null){
//返回空表示用户不存在
return null;
}
//取出数据库存储的正确密码
String password =user.getPassword();
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities= {"test"};
//创建UserDetails对象,权限信息待实现授权功能时再向UserDetail中加入
UserDetails userDetails = User.withUsername(user.getUsername()).password(password).authorities(authorities).build();
return userDetails;
}
}
数据库中的密码加过密的,用户输入的密码是明文,我们需要修改密码格式器PasswordEncoder,原来使用的是NoOpPasswordEncoder,它是通过明文方式比较密码,现在我们修改为BCryptPasswordEncoder
,它是将用户输入的密码编码为BCrypt格式与数据库中的密码进行比对。
@Bean
public PasswordEncoder passwordEncoder() {
// //密码为明文方式
// return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
扩展用户身份信息,如何在jwt令牌中存储用户的昵称、头像、qq等信息?
在认证阶段DaoAuthenticationProvider
会调用UserDetailService
查询用户的信息,这里是可以获取到齐全的用户信息的。由于JWT令牌
中用户身份信息来源于UserDetails
,UserDetails中仅定义了username
为用户的身份信息,我们可以扩展username的内容 ,比如存入json数据内容作为username的内容。
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
XcUserMapper xcUserMapper;
/**
* @description 根据账号查询用户信息
* @param s 账号
* @return org.springframework.security.core.userdetails.UserDetails
* @author Mr.M
* @date 2022/9/28 18:30
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, s));
if(user==null){
//返回空表示用户不存在
return null;
}
//取出数据库存储的正确密码
String password =user.getPassword();
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities = {"p1"};
//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build();
return userDetails;
}
}
支持认证多样性:
1、支持账号和密码认证
采用OAuth2协议的密码模式即可实现。
2、支持手机号加验证码认证
用户认证提交的是手机号和验证码,并不是账号和密码。
3、微信扫码认证
基于OAuth2协议与微信交互,学成在线网站向微信服务器申请到一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过。
在前边我们自定义了UserDetailsService接口实现类,通过loadUserByUsername()方法根据账号查询用户信息。
而不同的认证方式提交的数据不一样,比如:手机加验证码方式会提交手机号和验证码,账号密码方式会提交账号、密码、验证码。
我们可以在loadUserByUsername()方法上作文章,将用户原来提交的账号数据改为提交json数据,json数据可以扩展不同认证方式所提交的各种参数。
- 认证用户请求参数
package com.xuecheng.ucenter.model.dto;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
/**
* @author Mr.M
* @version 1.0
* @description 认证用户请求参数
* @date 2022/9/29 10:56
*/
@Data
public class AuthParamsDto {
private String username; //用户名
private String password; //域 用于扩展
private String cellphone;//手机号
private String checkcode;//验证码
private String checkcodekey;//验证码key
private String authType; // 认证的类型 password:用户名密码模式类型 sms:短信模式类型
private Map<String, Object> payload = new HashMap<>();//附加数据,作为扩展,不同认证类型可拥有不同的附加数据。如认证类型为短信时包含smsKey : sms:3d21042d054548b08477142bbca95cfa; 所有情况下都包含clientId
}
- 自定义DaoAuthenticationProvider
@Slf4j
@Component
public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider {
@Autowired
public void setUserDetailsService(UserDetailsService userDetailsService) {
super.setUserDetailsService(userDetailsService);
}
//屏蔽密码对比
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
}
}
- 修改WebSecurityConfig类指定daoAuthenticationProviderCustom
@Autowired
DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(daoAuthenticationProviderCustom);
}
- 这些认证参数我们可以定义一个认证Service接口(AuthService )去进行各种方式的认证。
public interface AuthService {
/**
* @description 认证方法
* @param authParamsDto 认证参数
* @return com.xuecheng.ucenter.model.po.XcUser 用户信息
* @author Mr.M
* @date 2022/9/29 12:11
*/
XcUserExt execute(AuthParamsDto authParamsDto);
}
- loadUserByUsername
@Slf4j
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
ApplicationContext applicationContext;
/**
* @description 查询用户信息组成用户身份信息
* @param s AuthParamsDto类型的json数据
* @return org.springframework.security.core.userdetails.UserDetails
* @author Mr.M
* @date 2022/9/28 18:30
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
AuthParamsDto authParamsDto = null;
try {
//将认证参数转为AuthParamsDto类型
authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
} catch (Exception e) {
log.info("认证请求不符合项目要求:{}",s);
throw new RuntimeException("认证请求数据格式不对");
}
//开始认证
authService.execute(authParamsDto);
.....
扩展:谈谈你对继承的了解
继承的话其实就是父类中有一些公共的方法,那子类继承父类就拥有了父类中定义的方法,这样做的好处是,举一个例子吧,因为最近在看登录授权这块,要统一认证的话,其实有很多方式,比如说账号密码验证码、手机号验证码、微信扫码,那用户可能是选择不同的登录方式,最后的话其实就是根据用户选择的方式挑出对应的参数,最后其实也是要去数据库查数据的,最后其实会落脚到DaoAuthenticationProvider然后里面的获得userDetails,这个方法的话spring Security是要校验密码的,那其实如果微信扫码那就不需要密码,所以我们就继承了DaoAuthenticationProvider重写了获得userDetails的这个方法,添加我们的逻辑,比如二维码啊那我们就二维码验证,手机短信就手机短信验证,这其实就是一个继承的例子吧,就是说父类提供了一些基本的方法,我们大多数直接用就ok,非常方便,要是父类的方法不能满足我们的需求,我们只需要继承父类然后重写其中的方法就ok,其他方法还是和父类是一样的操作,也可以统一我们的方法,统一操作吧,非常方便。
账号密码的认证:我们已经实现了AuthService认证接口,下边实现该接口实现账号密码认证
/**
* @description 账号密码认证
* @author Mr.M
* @date 2022/9/29 12:12
* @version 1.0
*/
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
PasswordEncoder passwordEncoder;
@Override
public XcUserExt execute(AuthParamsDto authParamsDto) {
//账号
String username = authParamsDto.getUsername();
XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
if(user==null){
//返回空表示用户不存在
throw new RuntimeException("账号不存在");
}
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(user,xcUserExt);
//校验密码
//取出数据库存储的正确密码
String passwordDb =user.getPassword();
String passwordForm = authParamsDto.getPassword();
boolean matches = passwordEncoder.matches(passwordForm, passwordDb);
if(!matches){
throw new RuntimeException("账号或密码错误");
}
return xcUserExt;
}
}
修改UserServiceImpl类,根据认证方式使用不同的认证bean
/**
* @author Mr.M
* @version 1.0
* @description 自定义UserDetailsService用来对接Spring Security
* @date 2022/9/28 18:09
*/
@Slf4j
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
ApplicationContext applicationContext;
// @Autowired
// AuthService authService;
/**
* @description 查询用户信息组成用户身份信息
* @param s AuthParamsDto类型的json数据
* @return org.springframework.security.core.userdetails.UserDetails
* @author Mr.M
* @date 2022/9/28 18:30
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
AuthParamsDto authParamsDto = null;
try {
//将认证参数转为AuthParamsDto类型
authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
} catch (Exception e) {
log.info("认证请求不符合项目要求:{}",s);
throw new RuntimeException("认证请求数据格式不对");
}
//认证方法
String authType = authParamsDto.getAuthType();
AuthService authService = applicationContext.getBean(authType + "_authservice",AuthService.class);
XcUserExt user = authService.execute(authParamsDto);
return getUserPrincipal(user);
}
/**
* @description 查询用户信息
* @param user 用户id,主键
* @return com.xuecheng.ucenter.model.po.XcUser 用户信息
* @author Mr.M
* @date 2022/9/29 12:19
*/
public UserDetails getUserPrincipal(XcUserExt user){
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities = {"p1"};
String password = user.getPassword();
//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
UserDetails userDetails = User.withUsername(userString).password(password ).authorities(authorities).build();
return userDetails;
}
}
验证码:
添加验证码服务的流程:
微信二维码流程:
本项目认证服务需要做哪些事?
1、需要定义接口接收微信下发的授权码。
2、收到授权码调用微信接口申请令牌。
3、申请到令牌调用微信获取用户信息
4、获取用户信息成功将其写入本项目用户中心数据库。
5、最后重定向到浏览器自动登录。
微服务直接的远程调用用feign
微服务与第三方之间的远程调用用restTemplate
用户授权:
资源服务授权流程:
- 在资源服务集成Spring Security
在需要授权的接口处使用@PreAuthorize(“hasAuthority(‘权限标识符’)”)进行控制
下边代码指定/course/list接口需要拥有xc_teachmanager_course_list 权限。 - 在统一异常处理处解析此异常信息
@ResponseBody
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(Exception e) {
log.error("【系统异常】{}",e.getMessage(),e);
e.printStackTrace();
if(e.getMessage().equals("不允许访问")){
return new RestErrorResponse("没有操作此功能的权限");
}
return new RestErrorResponse(CommonError.UNKOWN_ERROR.getErrMessage());
}
授权相关的数据模型:
五张表:
xc_user:用户表,存储了系统用户信息,用户类型包括:学生、老师、管理员等
xc_role:角色表,存储了系统的角色信息,学生、老师、教学管理员、系统管理员等。
xc_user_role:用户角色表,一个用户可拥有多个角色,一个角色可被多个用户所拥有
xc_menu:模块表,记录了菜单及菜单下的权限
xc_permission:角色权限表,一个角色可拥有多个权限,一个权限可被多个角色所拥有
数据库查询用户的权限:
步骤:
查询用户的id
查询用户所拥有的角色
查询用户所拥有的权限
例子:
SELECT * FROM xc_menu WHERE id IN(
SELECT menu_id FROM xc_permission WHERE role_id IN(
SELECT role_id FROM xc_user_role WHERE user_id = ‘49’
)
)
口述:用户权限的管理是用springSecurity,定义了五张表来确定用户所拥有的权限,将数据库查到的权限写到jwt当中去,其实就是重写了dao层的这个权限provider中的获得用户userdetiles这个方法,将查出来的权限写到jwt中的权限数组中。之后这个jwt就有了权限数组,后面我们访问其他资源时,根据方法上面的@PreAuthorize这个注解后面指定的权限,去在jwt权限列表中查,有的话可以访问,没有就拒绝访问抛异常,就实现了不同资源的权限的一个管理。
选课学习:
- 选课流程: 选课信息存入选课记录表,免费课程被选课除了进入选课记录表同时进入我的课程表,收费课程进入选课记录表后需要经过下单、支付成功才可以进入我的课程表。
选课是将课程加入我的课程表的过程。
对免费课程选课后可直接加入我的课程表,对收费课程选课后需要下单支付成功系统自动加入我的课程表
口述:根据用户想要学习的课程id,到内容管理服务查询课程发布表看看这个课程是免费还是收费,免费课程添加选课记录、添加我的课程表,收费课程添加选课记录
- 支付流程:
用户去学习收费课程时引导其去支付,如下图:
点击“支付宝支付”此时打开支付二维码,用户扫码支付。
所以首先需要生成支付二维码,用户扫描二维码开始请求支付宝下单,在向支付宝下单前需要添加选课记录、创建商品订单、生成支付交易记录。
执行流程:
1、前端调用学习中心服务的添加选课接口。
2、添加选课成功请求订单服务生成支付二维码接口。
3、生成二维码接口:创建商品订单、生成支付交易记录、生成二维码。
4、将二维码返回到前端,用户扫码。
用户扫码的流程:
1、用户输入支付密码,支付成功。
2、接收第三方平台通知的支付结果。
3、根据支付结果更新支付交易记录的支付状态为支付成功。
订单支付模式的核心由三张表组成:订单表、订单明细表、支付交易记录表。
编写创建商品订单方法时,商品订单的数据来源于选课记录,在订单表需要存入选课记录的ID,这里需要作好幂等处理。
为什么创建支付交易记录?
在请求微信或支付宝下单接口时需要传入 商品订单号,在与第三方支付平台对接时发现,当用户支付失败或因为其它原因最终该订单没有支付成功,此时再次调用第三方支付平台的下单接口发现报错“订单号已存在”,此时如果我们传入一个没有使用过的订单号就可以解决问题,但是商品订单已经创建,因为没有支付成功重新创建一个新订单是不合理的。
解决以上问题的方案是:
1、用户每次发起都创建一个新的支付交易记录 ,此交易记录与商品订单关联。
2、将支付交易记录的流水号传给第三方支付系统下单接口,这样就即使没有支付成功就不会出现上边的问题。
3、需要提醒用户不要重复支付。
交易完成后使用mq通知学习中心服务,更新选课记录、向我的课程表插入记录
:将课程状态改为可以学习的状态(将课程从课程记录表加到我们课程表,并修改课程状态)
订单服务通过消息队列将支付结果发给学习中心服务:订单服务收到第三方支付成功的结果时,会将消息保存到数据库(消息的话其实就是记录业务id,类型啊这些)并吧消息发送到交换机,消费者那边的话就可以取出业务id执行相应逻辑操作,比如这个项目中,将课程id记录,然后消费者拿到课程id后会将与之对应的课程保存到我们课程表中,跟新状态,表示用户可以学习了。为了避免消息丢失问题,将队列交换机消息持久化、发送者这里要写callback方法,就是使用correlationData指定回调方法成功返回ack并且删除数据库中的这个消息,失败的话就不删除了,消费者这边呢使用消息失败重试。
-
在线学习流程:
-
免费课程续期:
项目优化,为提高接口的QPS,redis缓冲优化,避免很多请求都直接去数据库查询。
策略:在课程发布之后,将发布的课程信息(课程基本信息、课程教学计划等)缓存到redis,减少查询数据库的压力。
-
缓存穿透:在高并发的情况下,当查询一个数据库不存在的信息时,这些请求肯定redis中也没有,就会都落在数据库上,造成数据库瞬间压力过大,连接数等资源用完,最终数据库拒绝连接不可用。
- 增加对请求的校验,比如参数传过来的检验,明显参数不合法就不让它去查了。
- 布隆过滤器:它其实是类似这种散列的形式,也是根据key然后hash存储id,它会经过好几次hash将id存在好几个位置,当我们请求传过来的id在布隆过滤器中不存在时,表明数据库中也不存在,因为里面存的都是数据库中的,相当于提前判断吧,当请求的在数据库中不存在时也不让它访问。
- 写入redis为空:这种我们让它去查,查完如果不存在我们就写入redis为空,后续的也就不走数据库了。
-
缓存雪崩:大量key(同一类型,比如查课程,请求路径那些都一样就具体的课程id不一样)拥有了相同的过期时间,这时有很多请求都落在数据库上。
- 设置不同过期时间:对同一类型信息的key设置不同的过期时间
- 同步锁:控制查询数据库的线程
- 缓存预热:不用等到请求到来再去查询数据库存入缓存,可以提前将数据存入缓存。
-
缓存击穿:针对同一个热点数据,当热点数据失效后同时去请求数据库,瞬间耗尽数据库资源,导致数据库无法使用。(比如某手机新品发布,当缓存失效时有大量并发到来导致同时去访问数据库)
- 同步锁:查询数据库只进一个,查好后写入redis,后续直接在redis中取
- 设置key不过期
分布式锁:
用了同步锁解决了缓存击穿、缓存雪崩的问题,保证同一个key过期后只会查询一次数据库。
问题:如果将同步锁的程序分布式部署在多个虚拟机上则无法保证同一个key只会查询一次数据库:虚拟机中的锁只能保证该虚拟机自己的线程去同步执行,无法跨虚拟机保证同步执行。
思路:
现在要实现分布式环境下所有虚拟机中的线程去同步执行,就需要让多个虚拟机去共用一个锁,虚拟机可以分布式部署,锁也可以分布式部署
- 基于数据库实现分布锁
利用数据库主键唯一性的特点,或利用数据库唯一索引的特点,多个线程同时去插入或更新相同的记录,谁插入或跟新成功谁就抢到锁。 - 基于redis实现锁
基于set nx,谁执行成功谁获得锁,操作完释放锁,就在finally中释放这个锁,为避免获得锁的线程它没有释放锁就挂了,导致后续都拿不到锁,设置锁的过期时间,这其实又会引发问题,当还在执行业务,锁到期释放了,那么后续查询同一个请求的这个线程就会拿到锁,当前线程执行完finally要释放锁,那其实删掉的是线程2的锁,其实针对这些问题redis引入了redission分布式锁,解决了上面的问题,比如说锁的续约,它会有一个线程来去看当前任务执行完没有,没有的话给锁加时间,解决了这种还在处理任务锁就到期的问题。
Redisson底层采用的是Netty 框架,它和java兼容的非常好,基于Java的Lock接口实现分布式锁
1 总结
spring securety:
它其实主要就做了两件事,认证和授权。
就是在filter链中,如果需要认证,那它就交给认证处理器,如果需要授权,那它就交给授权处理器。认证授权是基于oauth2协议的,微信下发授权码,携带该授权码拿到令牌,再拿令牌去请求服务获得用户的信息。
JWT令牌:
JWT它是一种无状态的认证的形式,服务端不需要存储session的信息,其实也可以避免session粘贴(集群下一个实例存了session,还要向其他的实例存),客户端首先请求认证服务生成JWT令牌,之后就可以拿着这个令牌去请求其他的资源服务了。