仿B站项目文档

快速上手

项目简介

imitation-bilibili 是一套基于当下前沿技术的的前后端分离(前台项目仍在开发中)的以学习为目的的项目,配备详细的文档手把手教你搭建起一个高性能的弹幕视频网站,前台系统主要包括 用户/视频检索模块、文件模块、用户订阅、用户中心、评论模块、视频投稿、视频播放和实时防遮挡弹幕等主要功能模块组成。

项目地址

开发环境

  • MinIO Latest
  • ElasticSearch/Kibana 8.5.0
  • MySQL 8.0.27
  • Redis 7.0
  • JDK 17
  • RocketMQ 5.0
  • XXL-JOB 2.3.0
  • Node 12.14.0

环境配置

注意:个人习惯以 /opt/xxx 为docker数据目录,以下命令均可直接复制粘贴执行,注意root权限执行,修改一下必要的地方,如密码、IP等等

Minio

rm -rf /opt/minio
mkdir -p /opt/minio/{config,data}
cat <<EOF> /opt/minio/docker-compose.yaml
version: '3'
services:
  minio-server:
    image: minio/minio
    container_name: minio
    restart: always
    ports:
    - "9000:9000"
    - "9001:9001"
    environment:
      - MINIO_ROOT_USER=minio
      - MINIO_ROOT_PASSWORD=<YOUR_PASSWORD>
      - MINIO_PROMETHEUS_AUTH_TYPE=public
    volumes:
    - /opt/minio/data:/data
    - /opt/minio/config:/root/.minio
    command: server /data --console-address="0.0.0.0:9001"
EOF

docker stop minio && docker rm minio
cd /opt/minio && docker-compose up -d

# ACCESS_KEY SECRET_KEY 在这里定义不起作用
# 可以登录UI界面手动生成

docker启动后访问 IP:9001 来到控制台页面,创建accessKeysecretKey ,配置在项目 application-dev.yml

image-20221216120842639
minio:
  endpoint: http://IP:PORT # Minio服务所在地址
  accessKey: jey5fGaOYDCg6iPB # 访问的key,填上自己的
  secretKey: snJFuPAlLuJ8Mu9n9gRaytYV3V87WucK # 访问的秘钥,填上自己的

ElasticSearch/Kibana

  1. ElasticSearch
# 创建
rm -rf /opt/elasticsearch
mkdir -p /opt/elasticsearch/{config,plugins,data}

cat <<EOF> /opt/elasticsearch/config/elasticsearch.yml
xpack.security.enabled: true
xpack.license.self_generated.type: basic
xpack.security.transport.ssl.enabled: false  # 不配报错
xpack.security.enrollment.enabled: true
http.host: 0.0.0.0
# xpack.security.enabled: false
EOF
chmod -R 777 /opt/elasticsearch
docker stop elasticsearch && docker rm elasticsearch
docker run --name elasticsearch -p 9200:9200 -p 9300:9300 \
--net elastic \
-e "discovery.type=single-node" \
-e ES_JAVA_OPTS="-Xms1024m -Xmx1024m" \
-v /opt/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v /opt/elasticsearch/data:/usr/share/elasticsearch/data \
-v /opt/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
-d elasticsearch:8.5.0
# 不要设置自启,怕死机 --restart=always
docker logs -f elasticsearch

等待es启动后(访问IP:9200 查看是否有反应即可)

# 重置下面两个密码
docker exec -it elasticsearch bin/elasticsearch-reset-password -u elastic  -i  # -i 表示自定义密码
docker exec -it elasticsearch bin/elasticsearch-reset-password -u kibana_system -i  # 此密码对应下面的 111111,请连带修改
  1. Kibana
docker stop kibana && docker rm kibana

rm -rf /opt/kibana
mkdir -p /opt/kibana/{config,data}
docker run --name kibana -p 5601:5601 -d kibana:8.5.0
# 拷贝配置文件
docker cp kibana:/usr/share/kibana/config/kibana.yml /opt/kibana/config/kibana.yml
cat <<EOF> /opt/kibana/config/kibana.yml
server.host: "0.0.0.0"  # 不配报错
# xpack.reporting.kibanaServer.hostname: localhost
server.shutdownTimeout: "5s"
elasticsearch.hosts: [ "http://elasticsearch:9200" ]
elasticsearch.username: "kibana_system"  # 不能用 elastic,es高版本不允许使用使用超级用户
elasticsearch.password: "111111"
i18n.locale: "zh-CN"
EOF

docker stop kibana && docker rm kibana
sudo docker run --name kibana \
--net elastic \
-v /opt/kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml \
-p 5601:5601 -d kibana:8.5.0

docker logs -f kibana

补充说明:es高版本禁用了super账户(elastic)直接写在配置文件中这种做法,我们可以用一个普通预设的账户写在配置文件中,web端重新输入超级账号elastic账号密码即可

RocketMQ

详细参考 xuchengen/rocketmq - Docker Image | Docker Hub

rm -rf /opt/rocketmq
mkdir -p /opt/rocketmq/data
docker stop rocketmq && docker rm rocketmq
# docker inspect: /var/lib/docker/volumes/rocketmq_data/_data
docker volume rm rocketmq_data
docker volume create rocketmq_data
docker run -itd \
 --name=rocketmq \
 --hostname rocketmq \
 --restart=always \
 -p 8080:8080 \
 -p 9876:9876 \
 -p 10909:10909 \
 -p 10911:10911 \
 -p 10912:10912 \
 -v rocketmq_data:/home/app/data \
 -v /etc/localtime:/etc/localtime \
 -v /var/run/docker.sock:/var/run/docker.sock \
 --net=host \
 xuchengen/rocketmq:latest

MySQL

#      ===================== 必须以root执行  =====================
#清空环境!!!!!!!!!
sudo usermod -aG docker $USER
docker stop mysql
docker rm mysql
rm -rf /opt/mysql
docker run -p 3306:3306 --name mysql \
-e MYSQL_ROOT_PASSWORD=testpassword  \
-d mysql:8.0.27
# 拷贝配置文件
sudo mkdir -p /opt/mysql/conf
docker cp mysql:/etc/mysql/my.cnf /opt/mysql/conf/my.cnf  # 复制配置文件
docker stop mysql
docker rm mysql
# 启动服务器
docker run -p 3306:3306 --name mysql \
-v /opt/mysql/data:/var/lib/mysql \
-v /opt/mysql/conf/my.cnf:/etc/mysql/my.cnf \
-e MYSQL_ROOT_PASSWORD=<YOUR_PASSWORD>  \
--restart=always \
-d mysql:8.0.27 --lower_case_table_names=1     # 8.0.31 删除了docker镜像内的 /etc/mysql/my.cnf 配置文件

Redis

mkdir -p /opt/redis/{conf,data}
cd  /opt/redis/conf
# 先在conf目录下创建配置文件!
wget http://download.redis.io/redis-stable/redis.conf

# 以下参数均可以在配置文件配置,这里配置优先级更高!
docker stop redis
docker rm redis
docker run -p 16379:16379 --name redis \
-v /opt/redis/data:/data \
-v /opt/redis/conf/redis.conf:/etc/redis/redis.conf \
-d --hostname redis redis:7.0 redis-server /etc/redis/redis.conf \
--appendonly yes \
--requirepass <这台Redis的访问密码> \
--replica-announce-ip <本机IP> \
--bind 0.0.0.0 \
--rdbcompression no \
--masterauth <主节点的密码> \  # 单机时可删除此行
--port 16379

XXL-JOB

ARM 架构

docker stop xxl-job-admin && docker rm xxl-job-admin
docker run --name xxl-job-admin -p 8989:8080 \
-e PARAMS="\
 --spring.datasource.url=jdbc:mysql://IP:端口/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai \
--spring.datasource.username=root \
--spring.datasource.password=L200107208017./@ \
--xxl.job.accessToken=juneqqq" \
-v /opt/xxl-job/logs:/data/applogs \
myteam-p-docker.pkg.coding.net/public/general/xxl-job-admin:2.3.1   # arm 架构

X86

docker run \
 -e PARAMS=' \
 --spring.datasource.url=jdbc:mysql://IP:端口/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai \
 --spring.datasource.username=test \
 --spring.datasource.password=test!1234 \
 --xxl.job.accessToken=juneqqq' \
 -p 8080:8080 \
 -v /tmp:/data/applogs \
 --name xxl-job-admin \
 -d xuxueli/xxl-job-admin:{指定版本} 

本机运行

直接Fork源码即可,一定注意修改必要配置项,并设置如下环境

运行时注意加上dev 的profile

最简单的方式:

image-20221216124136219

最后,接口文档访问 localhost:15005/swagger-ui.html即可

技术架构

RocketMQ · 官方网站 | RocketMQ (apache.org)

后端技术选型

技术 版本 说明 官网
Spring Boot 3.0.0 容器 + MVC 框架 https://spring.io/projects/spring-boot
MyBatis 3.5.11 ORM 框架 http://www.mybatis.org
MyBatis-Plus 3.5.2 MyBatis 增强工具 https://baomidou.com/
JJWT 0.11.5 JWT 登录支持 https://github.com/jwtk/jjwt
Lombok 1.18.24 简化对象封装工具 https://github.com/projectlombok/lombok
Caffeine 3.1.2 本地缓存支持 https://github.com/ben-manes/caffeine
Redis 7.0 分布式缓存支持 https://redis.io
RocketMQ 5.0.0 开源消息中间件 https://rocketmq.apache.org
MinIO latest 文件存储服务 https://www.minio.org.cn
Docker - 应用容器引擎 https://www.docker.com/
Springdoc-openapi 2.0.0 Swagger 3 接口文档自动生成 https://github.com/springdoc/springdoc-openapi
Sentinel 1.8.6 流量控制组件 https://github.com/alibaba/Sentinel
MySQL 8.0.27 数据库服务 https://www.mysql.com
XXL-JOB 2.3.1 分布式任务调度平台 https://www.xuxueli.com/xxl-job
Elasticsearch 8.5.0 搜索引擎服务 https://www.elastic.co

编码规范

  • 规范方式:严格遵守阿里编码规约。
  • 命名统一:简介最大程度上达到了见名知意。
  • 分包明确:层级分明可快速定位到代码位置。
  • 注释完整:描述性高大量减少了开发人员的代码阅读工作量。
  • 工具规范:使用统一jar包避免出现内容冲突。
  • 代码整洁:可读性、维护性高。
  • 依赖版本:所有依赖均使用当前最新可用版本以便新技术学习。

包结构

io
 +- juneqqq
    +- cache -- 缓存相关配置
    +- config -- 全局配置类
    +- constant -- 基本的常量
    +- controller -- HTTP请求接口层
    +- core -- 项目核心模块,包括各种工具、配置和常量等
    |   +- annotation -- 业务相关自定义注解 
    |   +- aspect -- 自定义切面
    |   +- auth -- 权限相关
    |   +- exception -- 全局异常处理
    |   +- filter -- 全局过滤器 
    |   +- interceptor -- 全局拦截器
    |   +- task -- xxljob定时任务
    |   +- util -- 业务相关工具 
    |   +- wrapper -- 装饰器
    +- dao -- 数据访问层,与底层 MySQL 进行数据交互
    +- pojo -- 数据传输对象以及视图对象
    +- service -- 相对具体的业务逻辑服务层  
    +- util -- 依赖的工具类
    +- ApiBilibiliApp.java.java -- 项目启动类

技术要点

MySQL 新特性

  1. 新增 JSON 数据类型

    在 5.7.8 版本之后,MySQL 新增了一个原生的 JSON 数据类型,JSON 值将不再以字符串的形式存储,而是采用一种允许快速读取文本元素(document elements)的内部二进制(internal binary)格式;在 JSON 列插入或者更新的时候将会自动验证 JSON 文本,未通过验证的文本将产生一个错误信息。

    之前如果要存储 JSON 类型的数据的话我们只能自己做 JSON.stringify() 和 JSON.parse() 的操作,而且没办法针对 JSON 内的数据进行查询操作,所有的操作必须读取出来 parse 之后进行,非常的麻烦。原生的 JSON 数据类型支持之后,我们就可以直接对 JSON 进行数据查询和修改等操作了,较之前会方便非常多。

    MySQL 8 大幅改进了对 JSON 的支持,在主从复制中,新增参数 binlog_row_value_options,控制 JSON 数据的传输方式,允许对于 JSON 类型部分修改,在binlog中只记录修改的部分,减少JSON大数据在只有少量修改的情况下,对资源的占用。

  2. 默认字符集由 latin1 变为 utf8mb4

    在 MySQL 8.0 版本之前,默认字符集为 latin1,utf8 指向的是 utf8mb3,8.0版本默认字符集为 utf8mb4,utf8 默认指向的也是 utf8mb4。

  3. MyISAM 系统表全部换成 InnoDB 表

    MySQL 8.0 版本之后系统表全部换成了事务型的 Innodb 表,默认的 MySQL 实例将不包含任何 MyISAM 表,除非手动创建 MyISAM 表。

  4. 自增变量持久化

    在 MySQL 8.0 之前的版本,自增主键 AUTO_INCREMENT 的值如果大于 max(primary key)+1,在 MySQL 重启后,会重置 AUTO_INCREMENT=max(primary key)+1,这种现象在某些情况下会导致业务主键冲突或者其他难以发现的问题。

  5. DDL 原子化

    MySQL 8.0 版本之后 InnoDB 表的 DDL 支持事务完整性,要么成功要么回滚,例如,数据库里只有一个t1表,执行drop table t1,t2语句试图删除t1,t2两张表,在 5.7 中,执行报错,但是 t1 表被删除,在 8.0 中执行报错,但是 t1 表没有被删除,证明了 8.0 DDL操作的原子性,要么全部成功,要么失败回滚。

  6. 参数修改持久化

    MySQL 8.0 版本支持在线修改全局参数并持久化,通过加上 PERSIST 关键字,可以将修改的参数持久化到新的配置文件(mysqld-auto.cnf)中,重启 MySQL 时,可以从该配置文件获取到最新的配置参数。

  7. group by 不再隐式排序

    MySQL 8.0 对于group by 字段不再隐式排序,如需要排序,必须显式加上 order by 子句。

  8. 支持不可见索引

    MySQL 8.0 支持不可见索引, 使用INVISIBLE关键字在创建表或者进行表变更中设置索引是否可见,索引不可见只是在查询时优化器不使用该索引,即使使用 force index,优化器也不会使用该索引,同时优化器也不会报索引不存在的错误,因为索引仍然真实存在,在必要时,也可以快速的恢复成可见。

  9. 新增 innodb_dedicated_server 参数

    MySQL 8.0 新增 innodb_dedicated_server 参数,能够让InnoDB根据服务器上检测到的内存大小自动配置 innodb_buffer_pool_size,innodb_log_file_size,innodb_flush_method 三个参数。

  10. 增加角色管理

    MySQL 8.0 增加角色管理,通常,MySQL 数据库拥有多个相同权限集合的用户。以前,向多个用户授予和撤销权限的唯一方法是单独更改每个用户的权限,假如用户数量比较多的时候,这是非常耗时的,为了用户权限管理更容易,MySQL 提供了一个名为 role 的新对象,它是一个命名的特权集合。

  11. 克隆功能

    MySQL 8.0 clone 插件提供从一个实例克隆数据的功能,克隆功能提供了更有效的方式来快速创建MySQL实例,用于自动搭建从节点,也可用于备份 innodb 表,增强了 MySQL InnoDB Cluster。

    在 MySQL 克隆功能出现之前,如果想将一个单机MySQL实例升级为高可用实例,或者一个 MySQL 节点由于硬件故障等原因需要重建时首先需要通过 xtrabackup 或mydumper 等物理或逻辑备份工具从正常的 MySQL 节点上进行一个全量备份,然后基于这个全量备份配置正确的 Binlog 相关参数,最后通过 change master to 和 start slave 等命令使新建的 MySQL 节点与所需的 MySQL 节点建立复制关系等待一系列复杂的操作。

  12. binlog 日志压缩

    MySQL 从 8.0.20 增加了 binlog 日志事务压缩功能,开启压缩功能后,将事务信息使用 zstd 算法进行压缩,然后再写入 binlog 日志文件,降低了原文件占用的磁盘空间和网络带宽传输。

  13. 连接管理

    在 MySQL 8.0 版本中,对连接管理这一块,先后做了两个比较大的改变:一个是允许额外连接,另一个是专用的管理端口。在 MySQL 8.0 版本中,在当前连接数达到最大连接数时,服务端允许1个额外连接,可以让具有 CONNECTION_ADMIN 权限的用户连接进来,并且允许具有 SERVICE_CONNECTION_ADMIN 权限的用户,通过特定的 IP 和 PORT 连接上来,且没有连接数限制。

  14. 取消 Query Cache

    MySQL 8.0 开始,取消了查询缓存,经过时间的考验,MySQL 的工程团队发现启用缓存的好处并不多。

    首先,查询缓存的效果取决于缓存的命中率,只有命中缓存的查询效果才能有改善,因此无法预测其性能;其次,查询缓存的另一个大问题是它受到单个互斥锁的保护,在具有多个内核的服务器上,大量查询会导致大量的互斥锁争用;最后,相对来说,缓存越靠近客户端,获得的好处越大。

  15. 允许禁用 redo log

    MySQL 8.0.21 开始可以禁用 redo log 来提升数据库的写性能,但降低了安全性,适用于某些对安全要求较低的场景。

JDK 新特性

  1. 引入模块

    Java 9 开始引入了模块(Module),目的是为了管理依赖。使用模块可以按需打包 JRE 和进一步限制类的访问权限。

  2. 接口支持私有方法

    JAVA 9 开始,接口里可以添加私有方法,JAVA 8 对接口增加了默认方法的支持,在 JAVA 9 中对该功能又来了一次升级,现在可以在接口里定义私有方法,然后在默认方法里调用接口的私有方法。这样一来,既可以重用私有方法里的代码,又可以不公开代码。

  3. 匿名内部类支持钻石(diamond)运算符

    JAVA 5 就引入了泛型(generic),到了 JAVA 7 开始支持钻石(diamond)运算符:<>,可以自动推断泛型的类型;但是这个自动推断类型的钻石运算符不支持匿名内部类,在 JAVA 9 中也对匿名内部类做了支持。

  4. 增强的 try-with-resources

    JAVA 7 中增加了try-with-resources的支持,可以自动关闭资源,但需要声明多个资源变量时,需要在 try 中写多个变量的创建过程,JAVA 9 中对这个功能进行了增强,可以引用 try 代码块之外的变量来自动关闭。

  5. 弃用 new Integer()

    JAVA 9 开始弃用了 new Integer() 的方式来创建 Integer 对象,推荐通过静态工厂 Integer.valueOf() 的方式来替代,其它包装类类似。

  6. 局部变量的自动类型推断(var)

    JAVA 10 带来了一个很有意思的语法 - var,它可以自动推断局部变量的类型,以后再也不用写类型了,也不用靠 lombok 的 var 注解增强了,不过这个只是语法糖,编译后变量还是有类型的,使用时还是考虑下可维护性的问题,不然写多了可就成 JavaScript 风格了。

  7. Lambda 中的自动类型推断(var)

    JAVA 11 中对 Lambda 语法也支持了 var 这个自动类型推断的变量,通过 var 变量还可以增加额外的注解。

  8. java 命令增强

    以前编译一个 java 文件时,需要先 javac 编译为 class,然后再用 java 执行,JAVA 11 之后可以直接使用 java 命令。

  9. Java Flight Recorder 开源

    「Java Flight Recorder」 是个非常好用的调试诊断工具,不过之前是在 Oracle JDK 中, JAVA 11 后就开源了,OpenJDK 现在也可以用这个功能。

  10. 更简洁的 switch 语法

    JAVA 12 和 13 分别增强了 switch 的语法。

  11. instanceof 增强

    之前处理动态类型碰上要强转时,需要先 instanceof 判断一下,然后再强转为该类型处理,JAVA 12 之后 instanceof 支持直接类型转换了,不需要再来一次额外的强转。

  12. 文本块(Text Block)的支持

    JAVA 13 中帮你解决了大段带换行符的字符串报文的问题,增加了文本块(""")的支持,可以不通过换行符换行拼字符串,而且不需要转义特殊字符,就像用模板一样。

  13. 新增 record 类型

    JAVA 14 新增 record 类型,干掉复杂的 POJO 类,一般我们创建一个 POJO 类,需要定义属性列表,构造函数,getter/setter方法,比较麻烦,JAVA 14 为我们带来了一个便捷的创建类的方式 - record。

    不过这个只是一个语法糖,编译后还是一个 Class,和普通的 Class 区别不大。

  14. 更直观的 NullPointerException 提示

    JAVA 14 优化了 NullPointerException 的提示,让你更容易定位到哪个对象为空。

  15. 新增 jpackage 打包工具

    JAVA 14 新增 jpackage 打包工具,可以直接打包二进制程序,再也不用装 JRE 了。

    之前如果想构建一个可执行的程序,还需要借助三方工具,将 JRE 一起打包,或者让客户电脑也装一个 JRE 才可以运行我们的 JAVA 程序。

    现在 JAVA 直接内置了 jpackage 打包工具,帮助你一键打包二进制程序包。

  16. 新增封闭(Sealed )类

    JAVA 的继承目前只能选择允许继承和不允许继承(final 修饰),JAVA 15 新增了一个封闭(Sealed )类的特性,可以指定某些类才可以继承。

  17. 新增垃圾回收器

    JAVA 15 中,两款垃圾回收器ZGC 和 Shenandoah 正式登陆(默认 G1 ),性能更强,延迟更低。

注:Spring Framework 6 和 Spring Boot 3 的应用程序运行时至少需要JDK 17。

SpringBoot 新特性

  1. 优雅关机

    Spring Boot 2.3.0 配置关机缓冲时间后,在关闭时,Web服务器将不再允许新请求,并且将等待缓冲时间以使活动请求完成。

    目前内置的四个嵌入式 Web 服务器(Jetty,Reactor Netty,Tomcat和Undertow)以及响应式和基于 Servlet 的 Web 应用程序都支持优雅关机。

  2. Docker 支持

    Spring Boot 2.3.0 添加了部分功能用来帮助将 Spring Boot 应用直接打包到 Docker 镜像。

    • 支持 Cloud Native Buildpacks 构建镜像;
    • maven 插件 增加 spring-boot:build-image 、gradle 增加 bootBuildImage task 帮助快速构建镜像;
    • 支持 jar 分层,更好的优化打包镜像过程。
  3. 全新的配置文件处理

    使用---在一个 yml 文件中分割多个配置,如果启用多个配置中有一样的配置项会相互覆盖,在 Spring Boot 2.4.0 版本中声明在最后面的会覆盖前面的配置。在 Spring Boot 2.4.0 之前的版本中取决于spring.profiles.active中声明的顺序。

    Spring Boot 2.4.0 版本之前使用文件名application-{profile}的方式指定配置标识,使用spring.profiles.active开启配置;Spring Boot 2.4.0 版本的用法是使用spring.config.activate.on-profile来指定配置标识,spring.profiles.active不能和它配置在同一个配置块中。

    spring:
      profiles:
        active: dev
    ---
    spring:
      config:
        activate:
          on-profile: dev
    secret:dev-password
    

    Spring Boot 2.4.0 版本以前使用spring.profilesspring.profiles.include配置组合,Spring Boot 2.4.0 版本之后,使用spring.profiles.group来配置组合。

    spring:
      profiles:
        active:
          - dev
        group:
          dev:
            - devdb
            - devmq
          test:
            - testdb
            - testmq
    ---
    spring:
      config:
        activate:
          on-profile: dev
    secret: dev-password
    ---
    spring:
      config:
        activate:
          on-profile: devdb
    db: devdb
    ---
    spring:
      config:
        activate:
          on-profile: devmq
    mq: devmq        
    
  4. 默认禁止循环依赖

    我们都知道,如果两个 Bean 互相注入对方就会存在循环引用问题,Spring Boot 2.6.0 这个版本已经默认禁止 Bean 之间的循环引用,如果存在循环引用就会启动失败报错。

  5. 支持自定义脱敏规则

    Spring Boot 2.6.0 版本可以清理 /env 和 /configprops 端点中存在的敏感值。另外,还可以通过添加类型为 SanitizingFunction 的 @Bean 类来配置自定义清理规则。

  6. 重要端点变更

    Spring Boot 2.6.0版本的环境变量 /env 端点已经默认不开放了,另外 Spring Boot 下的 /info 端点现在可以公开 Java 运行时信息了。

  7. Redis 连接池

    当 commons-pool2 在类路径下时,Redis(包括:Jedis 和 Lettuce)在 Spring Boot 2.6.0 之后的版本会自动开启连接池,也可以设置禁用连接池。

  8. 最低 Java 要求

    从Spring Boot 3.0 开始,Java 17 是最低版本,Java 8 不再被兼容。到正式版发行的时候 Java 19 也应该发行了。

  9. Jakarta EE 9

    Spring Boot 依赖于 Jakarta EE(原名 Java EE) 规范,3.0 已经升级到 Jakarta EE 9 版本。因此 Spring Boot 3.0 会使用 Servlet 5.0 规范和 JPA 3.0 规范。相关的三方依赖如果不支持这些规范,将减少或者移除这些依赖。所以相关的三方依赖请尽快根据 Jakarta EE 9 进行版本迭代。基于这个原因,目前不支持Jakarta EE 9 的类库将被移除,包含了一些知名三方类库,例如 EhCache3、Jersey、JOOQ、Thymeleaf 等等,直到这些类库适配 Jakarta EE 9。

  10. 声明式 HTTP 客户端

    Spring 6(Spring Boot 3) 开始支持新的声明式 HTTP 客户端。

  11. 新的 @AutoConfiguration 类

    Spring Boot 2.7/3 开始,@AutoConfiguration 类由 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件而不再是 META-INF/spring.factories 文件配置。

  12. @ConfigurationProperties 构造函数绑定

    Spring 6(Spring Boot 3) 开始,@ConfigurationProperties 类支持新的构造函数绑定,而无需显式 @ConstructorBinding。

全新的 Elasticsearch Java API Client

Elasticsearch Java API Client 是自 7.16 版本开始稳定发布的官方 Java API 客户端。该客户端为所有 Elasticsearch API 提供强类型请求和响应。主要特性如下:

  • 所有 Elasticsearch API 的强类型请求和响应。
  • 所有 API 的阻塞和异步版本。
  • 在创建复杂的嵌套结构时,使用流利的构建器和功能模式允许编写简洁易读的代码。
  • 通过使用对象映射器(例如 Jackson)或任何 JSON-B 实现来无缝集成应用程序类。
  • 将协议处理委托给 http 客户端,例如 Java Low Level REST Client (opens new window),该客户端负责处理所有传输级别的问题:HTTP 连接池、重试、节点发现等。

Elasticsearch Java API Client 是一个全新的客户端库,与旧的 High Level Rest Client (HLRC) 没有任何关系。它提供了一个独立于 Elasticsearch 服务器代码的库,并为所有 Elasticsearch 功能提供了一个非常一致且更易于使用的 API。

数据库设计

数据库设计规约

  1. 表达是与否概念的字段,必须使用 is_xxx 的方式命名,数据类型是 unsigned tinyint( 1 表示是, 0 表示否)。

说明: 任何字段如果为非负数,必须是 unsigned,坚持 is_xxx 的命名方式是为了明确其取值含义与取值范围。

正例: 表达逻辑删除的字段名 is_deleted, 1 表示删除, 0 表示未删除。

  1. 表名、字段名必须使用小写字母或数字, 禁止出现数字开头,禁止两个下划线中间只出现数字。数据库字段名的修改代价很大,字段名称需要慎重考虑。

说明: MySQL 在 Windows 下不区分大小写,但在 Linux 下默认是区分大小写。因此,数据库名、表名、字段名,都不允许出现任何大写字母,避免节外生枝。

  1. 表名不使用复数名词。

说明: 表名应该仅仅表示表里面的实体内容,不应该表示实体数量,对应于 DO 类名也是单数形式,符合表达习惯。

  1. 禁用保留字,如 desc、 range、 match、 delayed 等, 请参考 MySQL 官方保留字。

  2. 主键索引名为 pk_字段名;唯一索引名为 uk_字段名; 普通索引名则为 idx_字段名。

说明: pk_ 即 primary key; uk_ 即 unique key; idx_ 即 index 的简称。

  1. 小数类型为 decimal,禁止使用 float 和 double。

说明: 在存储的时候, float 和 double 都存在精度损失的问题,很可能在比较值的时候,得到不正确的结果。如果存储的数据范围超过 decimal 的范围,建议将数据拆成整数和小数并分开存储。

  1. 如果存储的字符串长度几乎相等,使用 char 定长字符串类型。

  2. varchar 是可变长字符串,不预先分配存储空间,长度不要超过 5000,如果存储长度大于此值,定义字段类型为 text,独立出来一张表,用主键来对应,避免影响其它字段索引效率。

  3. 表必备三字段: id, create_time, update_time。

说明: 其中 id 必为主键,类型为 bigint unsigned、单表时自增、步长为 1。 create_time, update_time的类型均为 datetime 类型,前者现在时表示主动式创建,后者过去分词表示被动式更新。

注意:更新数据表记录时,必须同时更新记录对应的 update_time 字段值为当前时间。

  1. 表的命名最好是遵循“业务名称_表的作用” 。

正例:book_info / book_chapter / user_bookshelf / user_comment / author_info

  1. 库名与应用名称尽量一致。

  2. 如果修改字段含义或对字段表示的状态追加时,需要及时更新字段注释。

  3. 字段允许适当冗余,以提高查询性能,但必须考虑数据一致。冗余字段应遵循:

  • 不是频繁修改的字段。
  • 不是唯一索引的字段。
  • 不是 varchar 超长字段,更不能是 text 字段。

正例: 各业务线经常冗余存储小说名称,避免查询时需要连表(单体应用)或跨服务(微服务应用)获取。

  1. 单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。

说明: 如果预计三年后的数据量根本达不到这个级别,请不要在创建表时就分库分表。

  1. 合适的字符存储长度,不但节约数据库表空间、节约索引存储,更重要的是提升检索速度。

正例: 无符号值可以避免误存负数, 且扩大了表示范围。

  1. 业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。

说明: 不要以为唯一索引影响了 insert 速度,这个速度损耗可以忽略,但提高查找速度是明显的; 另外,即使在应用层做了非常完善的校验控制,只要没有唯一索引,根据墨菲定律,必然有脏数据产生。

  1. 超过三个表禁止 join。需要 join 的字段,数据类型保持绝对一致; 多表关联查询时,保证被关联的字段需要有索引。

说明: 即使双表 join 也要注意表索引、 SQL 性能。

  1. 在 varchar 字段上建立索引时,必须指定索引长度,没必要对全字段建立索引,根据实际文本区分度决定索引长度。

说明: 索引的长度与区分度是一对矛盾体,一般对字符串类型数据,长度为 20 的索引,区分度会高达 90%以上,可以使用 count(distinct left(列名, 索引长度))/count(*)的区分度来确定。

  1. 创建索引时避免有如下极端误解:
  • 索引宁滥勿缺。 认为一个查询就需要建一个索引。
  • 吝啬索引的创建。 认为索引会消耗空间、 严重拖慢记录的更新以及行的新增速度。
  • 抵制惟一索引。 认为惟一索引一律需要在应用层通过“先查后插” 方式解决。

数据库关系图

基本用户模块

image-20221216164117904

用户关注/粉丝模块

image-20221216164333629

视频模块

image-20221216165138274

弹幕模块

image-20221216165303001

文件模块

image-20221216165331131

权限控制模块

image-20221216170439716

项目开发

插件安装

  • 必装
    • Alibaba Java Code Guidelines - 阿里巴巴 Java 代码规范
    • SonarLint - 代码质量检测
    • Save Actions - 代码自动格式化
    • Git Commit Template - 使用规范模板创建 Git 提交消息
  • 选装
    • Translation - 翻译插件
    • Maven Helper - 分析 Maven 依赖,解决 Jar 冲突
    • EasyYapi - 帮助你导出文件中的 API 到 yapi/postman/markdown 或发起文件中的 API 请求
    • Codota - 代码智能提示
    • Search In Repository - 搜索 Maven 或者 NPM 的依赖信息
    • CamelCase - 多种命名格式(下划线、驼峰等)之间切换
    • Auto filling Java call arguments - 自动补全调用函数的参数
    • GenerateO2O - 生成一个对象并自动填充另一个对象的值
    • GenerateAllSetter - 一键调用一个对象的所有的set方法
    • SequenceDiagram - 调用链路自动生成时序图
    • Rainbow Brackets - 让你的括号变成不一样的颜色,防止错乱括号
    • HighlightBracketPair - 括号开始和结尾,高亮显示
    • Grep Console - 控制台日志 高亮
    • Key promoter X - 鼠标操作的快捷键提示
    • CodeGlance - 缩略图
    • VisualGC - 实时垃圾回收监控
    • arthas idea - java 在线诊断工具
    • Alibaba Cloud Toolkit - 通过图形配置的方式连接到云端部署环境并将应用程序快速部署到云端
  • 上班摸鱼
    • Leetcode Editor IDEA 在线刷题
    • GIdeaBrowser IDEA 内嵌 Web 浏览器

通用请求响应/分页

/**
 * @author june
 */
@Getter
public class R<T> {

    /**
     * 响应码
     */
    @Schema(description = "错误码,00000-没有错误")
    private final String code;

    /**
     * 响应消息
     */
    @Schema(description = "响应消息")
    private final String message;

    /**
     * 响应数据
     */
    @Schema(description = "响应数据")
    private T data;

    private R() {
        this.code = ErrorCodeEnum.OK.getCode();
        this.message = ErrorCodeEnum.OK.getMessage();
    }

    private R(ErrorCodeEnum errorCode) {
        this.code = errorCode.getCode();
        this.message = errorCode.getMessage();
    }

    private R(T data) {
        this();
        this.data = data;
    }

    /**
     * 业务处理成功,无数据返回
     */
    public static R<Void> ok() {
        return new R<>();
    }

    /**
     * 业务处理成功,有数据返回
     */
    public static <T> R<T> ok(T data) {
        return new R<>(data);
    }

    /**
     * 业务处理失败
     */
    public static R<Void> fail(ErrorCodeEnum errorCode) {
        return new R<>(errorCode);
    }
    /**
     * 系统错误
     */
    public static R<Void> error() {
        return new R<>(ErrorCodeEnum.SYSTEM_ERROR);
    }

    /**
     * 判断是否成功
     */
    public boolean isOk() {
        return Objects.equals(this.code, ErrorCodeEnum.OK.getCode());
    }
}
/**
 * 分页响应
 *
 * @param list 分页数据集
 */
public record PageResult<T>(
        @Schema(description = "总记录数") long total,
        @Schema(description = "页码") long current,
        @Schema(description = "每页大小") long size,
        @Schema(description = "分页数据集") List<T> list,
        @Schema(description = "整块自定义数据") T data
) {
    public static <T> PageResult<T> of(long current, long size, long total, List<T> list) {
        return new PageResult<>(total, current, size, list, null);
    }

    public static <T> PageResult<T> of(long current, long size, long total, T data) {
        return new PageResult<>(total, current, size, null, data);
    }
}

全局异常处理

  1. 创建错误枚举
@Getter
@AllArgsConstructor
public enum ErrorCodeEnum {

    /**
     * 正确执行后的返回
     */
    OK("200", "一切 ok"),

    /**
     * 一级宏观错误码,用户端错误
     */
    USER_ERROR("A0001", "用户端错误"),

    /**
     * 二级宏观错误码,用户注册错误
     */
    USER_REGISTER_ERROR("A0100", "用户注册错误"),

    /**
     * 用户未同意隐私协议
     */
    USER_NO_AGREE_PRIVATE_ERROR("A0101", "用户未同意隐私协议"),

    ...省略若干行
}
  1. 新建业务异常类
@EqualsAndHashCode(callSuper = true)
@Data
public class BusinessException extends RuntimeException {

    final ErrorCodeEnum errorCodeEnum;

    public BusinessException(ErrorCodeEnum errorCodeEnum) {
        // 不调用父类 Throwable的fillInStackTrace() 方法生成栈追踪信息,提高应用性能
        // 构造器之间的调用必须在第一行
        super(errorCodeEnum.getMessage(), null, false, false);
        this.errorCodeEnum = errorCodeEnum;
    }
}
  1. 创建全局异常处理Handler
@ControllerAdvice
@Slf4j
public class CommonGlobalExceptionHandler {
    @ResponseBody
    @ExceptionHandler(value = BusinessException.class)
    public R<Void> conditionExceptionHandler(BusinessException e){
        return R.fail(e.getErrorCodeEnum());
    }
    @ExceptionHandler(value = Exception.class)
    public R<Void> unknowException(Exception e){
        e.printStackTrace();
        log.error("捕捉到未知异常:"+e.getMessage());
        return R.fail(ErrorCodeEnum.SYSTEM_ERROR);
    }
}

跨域配置

/**
 * 跨域配置
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        //允许所有域名进行跨域调用
//        config.addAllowedOrigin("*");  // 这个匹配URL 形如:http://localhost:1024
        config.addAllowedOriginPattern("*");//替换这个
        //允许跨越发送cookie
        config.setAllowCredentials(true);
        //放行全部原始头信息
        config.addAllowedHeader("*");
        //允许所有请求方法跨域调用
        config.addAllowedMethod("*");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }

}

日志配置

<?xml version="1.0" encoding="UTF-8"?>
<!-- 日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,如果设置为WARN,则低于WARN的信息都不会输出 -->
<!-- scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true -->
<!-- scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。当scan为true时,此属性生效。默认的时间间隔为1分钟。 -->
<!-- debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。 -->
<configuration  scan="true" scanPeriod="10 seconds">

    <!--<include resource="org/springframework/boot/logging/logback/base.xml" />-->

    <contextName>logback</contextName>
    <!-- name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义变量后,可以使“${}”来使用变量。 -->
    <property name="logging.file.path" value="logs" />
    <property name="maxHistory" value="30" />
    <property name="maxFileSize" value="100MB" />

    <!-- 彩色日志(IDE下载插件才可以生效) -->
    <!-- 彩色日志依赖的渲染类 -->
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
    <conversionRule conversionWord="wex" converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter" />
    <conversionRule conversionWord="wEx" converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter" />
    <!-- 彩色日志格式 -->
    <property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>


    <!--输出到控制台-->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>debug</level>
        </filter>
        <encoder>
            <Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
            <!-- 设置字符集 -->
            <charset>UTF-8</charset>
        </encoder>
    </appender>


    <!--输出到文件-->

    <!-- 时间滚动输出 level为 DEBUG 日志 -->
    <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 正在记录的日志文件的路径及文件名 -->
        <!--        <file>${logging.file.path}/log_debug.log</file>-->
        <!--日志文件输出格式-->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset> <!-- 设置字符集 -->
        </encoder>
        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 日志归档 -->
            <fileNamePattern>${logging.file.path}/debug/log-debug-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>${maxFileSize}</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文件保留天数-->
            <maxHistory>${maxHistory}</maxHistory>
        </rollingPolicy>
        <!-- 此日志文件只记录debug级别的 -->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>debug</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>

    <!-- 时间滚动输出 level为 INFO 日志 -->
    <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 正在记录的日志文件的路径及文件名 -->
        <!--        <file>${logging.file.path}/log_info.log</file>-->
        <!--日志文件输出格式-->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 每天日志归档路径以及格式 -->
            <fileNamePattern>${logging.file.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>${maxFileSize}</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文件保留天数-->
            <maxHistory>${maxHistory}</maxHistory>
        </rollingPolicy>
        <!-- 此日志文件只记录info级别的 -->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>info</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>

    <!-- 时间滚动输出 level为 WARN 日志 -->
    <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 正在记录的日志文件的路径及文件名 -->
        <!--        <file>${logging.file.path}/log_warn.log</file>-->
        <!--日志文件输出格式-->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset> <!-- 此处设置字符集 -->
        </encoder>
        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${logging.file.path}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>${maxFileSize}</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文件保留天数-->
            <maxHistory>${maxHistory}</maxHistory>
        </rollingPolicy>
        <!-- 此日志文件只记录warn级别的 -->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>warn</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>


    <!-- 时间滚动输出 level为 ERROR 日志 -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 正在记录的日志文件的路径及文件名 -->
        <!--        <file>${logging.file.path}/log_error.log</file>-->
        <!--日志文件输出格式-->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset> <!-- 此处设置字符集 -->
        </encoder>
        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${logging.file.path}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>${maxFileSize}</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文件保留天数-->
            <maxHistory>${maxHistory}</maxHistory>
        </rollingPolicy>
        <!-- 此日志文件只记录ERROR级别的 -->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>

    <!--
        <logger>用来设置某一个包或者具体的某一个类的日志打印级别、
        以及指定<appender>。<logger>仅有一个name属性,
        一个可选的level和一个可选的addtivity属性。
        name:用来指定受此logger约束的某一个包或者具体的某一个类。
        level:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,
              还有一个特俗值INHERITED或者同义词NULL,代表强制执行上级的级别。
              如果未设置此属性,那么当前logger将会继承上级的级别。
        addtivity:是否向上级logger传递打印信息。默认是true。
    -->
    <!--<logger name="org.springframework.web" level="info"/>-->
    <!--<logger name="org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor" level="INFO"/>-->
    <!--
        使用mybatis的时候,sql语句是debug下才会打印,而这里我们只配置了info,所以想要查看sql语句的话,有以下两种操作:
        第一种把<root level="info">改成<root level="DEBUG">这样就会打印sql,不过这样日志那边会出现很多其他消息
        第二种就是单独给dao下目录配置debug模式,代码如下,这样配置sql语句会打印,其他还是正常info级别:
     -->


    <!--
        root节点是必选节点,用来指定最基础的日志输出级别,只有一个level属性
        level:用来设置打印级别,大小写无关:TRACE, DEBUG, INFO, WARN, ERROR, ALL 和 OFF,
        不能设置为INHERITED或者同义词NULL。默认是DEBUG
        可以包含零个或多个元素,标识这个appender将会添加到这个logger。
    -->
    <root level="info">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="DEBUG_FILE" />
        <appender-ref ref="INFO_FILE" />
        <appender-ref ref="WARN_FILE" />
        <appender-ref ref="ERROR_FILE" />
    </root>
</configuration>

集成MyBatisPlus

  1. 依赖
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-boot-starter</artifactId>
  <version>3.5.2</version>
  <exclusions>
    <exclusion>
      <groupId>org.mybatis</groupId>
      <artifactId>mybatis</artifactId>
    </exclusion>
    <exclusion>
      <groupId>org.mybatis</groupId>
      <artifactId>mybatis-spring</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.mybatis.spring.boot</groupId>
  <artifactId>mybatis-spring-boot-starter</artifactId>
  <version>3.0.0</version>
</dependency>
  1. 注解以及配置(此时配置文件配置mybatis-plus配置均失效)
@Configuration
@EnableTransactionManagement
@MapperScan("io.juneqqq.*.mapper")
public class MybatisPlusConfig implements MetaObjectHandler {

    /**
     * 分页插件,不配置的话 page 方法会很不稳定
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }


    /**
     * 插入操作时自动填充
     */
    @Override
    public void insertFill(MetaObject metaObject) {
        //设置属性值
        this.setFieldValByName("createTime", LocalDateTime.now(), metaObject);
        this.setFieldValByName("updateTime", LocalDateTime.now(), metaObject);
    }

    /**
     * 更新操作时自动填充
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        this.setFieldValByName("updateTime", LocalDateTime.now(), metaObject);
    }

    /**
     * 使用注入的数据源,生成 MybatisSqlSessionFactoryBean,执行代理逻辑
     */
    @Bean(name = "sqlSessionFactory")
    @Primary
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource, MybatisPlusInterceptor mybatisPlusInterceptor) throws Exception {

        MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setDefaultScriptingLanguage(MybatisXMLLanguageDriver.class);
        configuration.setJdbcTypeForNull(JdbcType.NULL);
        configuration.setMapUnderscoreToCamelCase(false);  // 驼峰转换 user_id -> userId

        factoryBean.setConfiguration(configuration);
        factoryBean.setDataSource(dataSource);
        factoryBean.setPlugins(mybatisPlusInterceptor);  // 这里注入分页插件!!!
        factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:mapper/**/*.xml"));  // mapper 扫描
        factoryBean.setTransactionFactory(new SpringManagedTransactionFactory());
        GlobalConfig globalConfig = new GlobalConfig();
        GlobalConfig.DbConfig dbConfig = new GlobalConfig.DbConfig();
        dbConfig.setTablePrefix("t_");   // 通用表前缀
        globalConfig.setDbConfig(dbConfig);
        factoryBean.setGlobalConfig(globalConfig);
        return factoryBean.getObject();
    }

    /**
     * 注入druid数据源
     */
    @ConfigurationProperties(prefix = "spring.datasource")
    @Bean
    public DataSource druid() {
        return new DruidDataSource();
    }
}

  1. 修复BUG
package org.springframework.core;

import java.io.IOException;

/**
 * 兼容 mybatis-plus 3.5.1
 * mybatis-plus 的 MybatisSqlSessionFactoryBean 中使用到了这个异常
 * Spring 6 开始移除了该异常
 * 在如上 package 处添加此类
 */
public class NestedIOException extends IOException {
}

本地/分布式缓存

  1. 本地缓存 caffeine 以及分布式缓存 redis 引入
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 自定义缓存管理器
@Configuration
@EnableCaching
public class CacheManagerConfig {
    @Resource
    private ApplicationContext context;

    /**
     * Caffeine 缓存管理器
     */
    @Bean(CacheConstant.CACHE_TYPE_CAFFEINE)
    public CacheManager caffeineCacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        List<CaffeineCache> caches = new ArrayList<>(CacheConstant.CacheEnum.values().length);
        for (CacheConstant.CacheEnum c : CacheConstant.CacheEnum.values()) {
            if (Objects.equals(c.getCacheType(), CacheConstant.CACHE_TYPE_CAFFEINE)) {
                Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                        .recordStats()
                        .maximumSize(c.getMaxSize());
                if (c.getTtl() > 0) {
                    caffeine.expireAfterWrite(Duration.ofSeconds(c.getTtl()));
                }
                // <=0 永久有效
                caches.add(new CaffeineCache(c.getName(), caffeine.build()));
            }
        }
        cacheManager.setCaches(caches);
        return cacheManager;
    }

    /**
     * redis 缓存管理器
     */
    @Primary
    @Bean(CacheConstant.CACHE_TYPE_REDIS)
    public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory);

        Map<String, Object> annotatedBeans = context.getBeansWithAnnotation(SpringBootApplication.class);
        String mainClassPath = annotatedBeans.isEmpty() ? null : annotatedBeans.values().toArray()[0].getClass().getName();
        assert mainClassPath != null;

        // 定制value序列化器
        FastJsonRedisSerializer<Object> serializer = new FastJsonRedisSerializer<>(Object.class);
        FastJsonConfig fastJsonConfig = serializer.getFastJsonConfig();
        fastJsonConfig.setWriterFeatures(JSONWriter.Feature.WriteClassName);
//        fastJsonConfig.setReaderFeatures(JSONReader.Feature.SupportAutoType); // 完全支持反序列化有类型安全问题
        // 指定可以反序列化的白名单
        ParserConfig.getGlobalInstance().addAccept(mainClassPath.substring(0, mainClassPath.lastIndexOf(".") + 1));

        // 整体配置
        RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration
                .defaultCacheConfig()
//                .disableCachingNullValues()   // 禁用空值缓存
                .prefixCacheNameWith(CacheConstant.REDIS_CACHE_PREFIX)
                .computePrefixWith(n -> n + ":")   // 单冒号而不是双冒号
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(serializer));


        Map<String, RedisCacheConfiguration> cacheMap = new LinkedHashMap<>(CacheConstant.CacheEnum.values().length);
        // 个性化定制
        for (CacheConstant.CacheEnum c : CacheConstant.CacheEnum.values()) {
            if (Objects.equals(c.getCacheType(), CacheConstant.CACHE_TYPE_REDIS)
                    && c.getTtl() > 0) {
                cacheMap.put(c.getName(), defaultCacheConfig
                        .entryTtl(Duration.ofSeconds(c.getTtl())));
            }
        }

        RedisCacheManager redisCacheManager =
                new RedisCacheManager(redisCacheWriter, defaultCacheConfig, cacheMap);

        redisCacheManager.setTransactionAware(true);
        redisCacheManager.initializeCaches();
        return redisCacheManager;
    }
}

集成ElasticSearch

  1. 依赖
<!--这个用的就是高版本的java client-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
  <version>3.0.0</version>
</dependency>
  1. es无需任何配置,修改配置文件es服务器地址即可。
  2. 利用注解定义Mapping,如
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Document(indexName = VideoIndex.NAME)
public class EsVideoDto {
    @Id
    @Field(type = FieldType.Long, index = false)
    private Long id;
    @Field(type = FieldType.Long)
    private Long userId;
    @Field(type = FieldType.Long)
    private Long fileId;
    @Field(type = FieldType.Text, searchAnalyzer = "ik_smart", analyzer = "ik_smart")
    private String nick; // 用户昵称【可检索】
    @Field(type = FieldType.Text, searchAnalyzer = "ik_smart", analyzer = "ik_smart")
    private String title; // 标题【检索重点】
    @Field(type = FieldType.Text, searchAnalyzer = "ik_smart", analyzer = "ik_smart")
    private String description; // 描述【检索 比重小】
    @Field(type = FieldType.Keyword, docValues = false, index = false)
    private String cover; // 封面链接
    @Field(type = FieldType.Integer)
    private Integer type; // 视频类型 0其他 1原创 2转载 3翻译
    @Field(type = FieldType.Integer)
    private Integer partition; // 分区 0其他 1音乐 2电影 3游戏 4鬼畜 5...

    @Field(type = FieldType.Integer)
    private Integer like; // 点赞量
    @Field(type = FieldType.Integer)
    private Integer coin; // 投币量
    @Field(type = FieldType.Integer)
    private Integer collection; // 收藏
    @Field(type = FieldType.Integer)
    private Integer duration;  // 时长
    @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second_millis)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    private LocalDateTime createTime;

    @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second_millis)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    private LocalDateTime updateTime;
    private List<VideoTag> videoTagList; // 标签列表

}
  1. 以上注解并不会在springboot启动时自动映射到es,需要手动加载。这里利用springboot @PostConstruct 功能实现项目启动自动加载Mapping
/**
     * 使配置注解生效,不能配置在配置文件处
     */
@PostConstruct
void initIndex() {
  Try.ofFailable(() -> elasticsearchTemplate.indexOps(EsVideoDto.class).putMapping())
    .onFailure(e ->
               log.warn("{} mapping 添加失败。若有必要,请在kibana使用\n DELETE {}", VideoIndex.NAME, VideoIndex.NAME));
}

RocketMQ集成

  1. 依赖
<dependency>
  <groupId>org.apache.rocketmq</groupId>
  <artifactId>rocketmq-client</artifactId>
  <version>5.0.0</version>
</dependency>
<dependency>
  <groupId>org.apache.rocketmq</groupId>
  <artifactId>rocketmq-spring-boot-starter</artifactId>
  <version>2.2.2</version>
  <exclusions>
    <exclusion>
      <groupId>org.apache.rocketmq</groupId>
      <artifactId>rocketmq-client</artifactId>
    </exclusion>
  </exclusions>
</dependency>
  1. 配置
@Slf4j
@Configuration
public class RocketMQConfig {

    @Value("${rocketmq.name.server.address}")
    private String nameServerAddr;


    @Bean
    public DefaultMQProducer testProducer() throws Exception {
        ...
    }

    @Bean
    public DefaultMQPushConsumer testConsumer() throws Exception {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(RocketMQConstant.GROUP_TEST);
        consumer.setNamesrvAddr(nameServerAddr);
        consumer.subscribe(RocketMQConstant.TOPIC_TEST, "*");
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) {
                ...
            }
        });
        consumer.start();
        return consumer;
    }


    @Bean("momentsProducer")
    public DefaultMQProducer momentsProducer() throws Exception {
        ...
    }

    /**
     * 给所有粉丝的订阅set添加一条记录
     */
    @Bean("momentsConsumer")
    public DefaultMQPushConsumer momentsConsumer() throws Exception {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(RocketMQConstant.GROUP_MOMENTS);
        consumer.setNamesrvAddr(nameServerAddr);
        consumer.subscribe(RocketMQConstant.TOPIC_MOMENTS, "*");
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) {
                ...
            }
        });
        consumer.start();
        return consumer;
    }

    @Bean("danmusProducer")
    public DefaultMQProducer danmusProducer() throws Exception {
        ...
    }

    @Bean("danmusConsumer")
    public DefaultMQPushConsumer danmusConsumer() throws Exception {
        // 实例化消费者
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(RocketMQConstant.GROUP_DANMUS);
        // 设置NameServer的地址
        consumer.setNamesrvAddr(nameServerAddr);
        // 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
        consumer.subscribe(RocketMQConstant.TOPIC_DANMU, "*");
        // 注册回调实现类来处理从broker拉取回来的消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) {
                ...
            }
        });
        // 启动消费者实例
        consumer.start();
        return consumer;
    }
}

分布式任务调度平台 XXL-JOB 集成与配置

介绍

XXL-JOB 是一个开箱即用的开源分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。由调度模块和执行模块构成:

  • 调度模块(调度中心):

负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码。调度系统与任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务模块; 支持可视化、简单且动态的管理调度信息,包括任务新建,更新,删除,GLUE开发和任务报警等,所有上述操作都会实时生效,同时支持监控调度结果以及执行日志,支持执行器Failover。

  • 执行模块(执行器):

负责接收调度请求并执行任务逻辑。任务模块专注于任务的执行等操作,开发和维护更加简单和高效; 接收“调度中心”的执行请求、终止请求和日志请求等。

XXL-JOB 架构图

XXL-JOB 将调度行为抽象形成“调度中心”公共平台,而平台自身并不承担业务逻辑,“调度中心”负责发起调度请求。

将任务抽象成分散的JobHandler,交由“执行器”统一管理,“执行器”负责接收调度请求并执行对应的JobHandler中业务逻辑。

因此,“调度”和“任务”两部分可以相互解耦,提高系统整体稳定性和扩展性;

XXL-JOB 的主要功能特性如下:

  1. 简单:支持通过 Web 页面对任务进行 CRUD 操作,操作简单,一分钟上手;
  2. 动态:支持动态修改任务状态、启动/停止任务,以及终止运行中任务,即时生效;
  3. 调度中心 HA(中心式):调度采用中心式设计,“调度中心”自研调度组件并支持集群部署,可保证调度中心 HA;
  4. 执行器 HA(分布式):任务分布式执行,任务”执行器”支持集群部署,可保证任务执行 HA;
  5. 注册中心: 执行器会周期性自动注册任务, 调度中心将会自动发现注册的任务并触发执行。同时,也支持手动录入执行器地址;
  6. 弹性扩容缩容:一旦有新执行器机器上线或者下线,下次调度时将会重新分配任务;
  7. 触发策略:提供丰富的任务触发策略,包括:Cron 触发、固定间隔触发、固定延时触发、API(事件)触发、人工触发、父子任务触发;
  8. 调度过期策略:调度中心错过调度时间的补偿处理策略,包括:忽略、立即补偿触发一次等;
  9. 阻塞处理策略:调度过于密集执行器来不及处理时的处理策略,策略包括:单机串行(默认)、丢弃后续调度、覆盖之前调度;
  10. 任务超时控制:支持自定义任务超时时间,任务运行超时将会主动中断任务;
  11. 任务失败重试:支持自定义任务失败重试次数,当任务失败时将会按照预设的失败重试次数主动进行重试;其中分片任务支持分片粒度的失败重试;
  12. 任务失败告警;默认提供邮件方式失败告警,同时预留扩展接口,可方便的扩展短信、钉钉等告警方式;
  13. 路由策略:执行器集群部署时提供丰富的路由策略,包括:第一个、最后一个、轮询、随机、一致性 HASH、最不经常使用、最近最久未使用、故障转移、忙碌转移等;
  14. 分片广播任务:执行器集群部署时,任务路由策略选择”分片广播”情况下,一次任务调度将会广播触发集群中所有执行器执行一次任务,可根据分片参数开发分片任务;
  15. 动态分片:分片广播任务以执行器为维度进行分片,支持动态扩容执行器集群从而动态增加分片数量,协同进行业务处理;在进行大数据量业务操作时可显著提升任务处理能力和速度。
  16. 故障转移:任务路由策略选择”故障转移”情况下,如果执行器集群中某一台机器故障,将会自动Failover切换到一台正常的执行器发送调度请求。
  17. 任务进度监控:支持实时监控任务进度;
  18. Rolling 实时日志:支持在线查看调度结果,并且支持以 Rolling 方式实时查看执行器输出的完整的执行日志;
  19. GLUE:提供Web IDE,支持在线开发任务逻辑代码,动态发布,实时编译生效,省略部署上线的过程。支持30个版本的历史版本回溯。
  20. 脚本任务:支持以GLUE模式开发和运行脚本任务,包括 Shell、Python、NodeJS、PHP、PowerShell等类型脚本;
  21. 命令行任务:原生提供通用命令行任务Handler(Bean任务,”CommandJobHandler”);业务方只需要提供命令行即可;
  22. 任务依赖:支持配置子任务依赖,当父任务执行结束且执行成功后将会主动触发一次子任务的执行, 多个子任务用逗号分隔;
  23. 一致性:“调度中心”通过DB锁保证集群分布式调度的一致性, 一次任务调度只会触发一次执行;
  24. 自定义任务参数:支持在线配置调度任务入参,即时生效;
  25. 调度线程池:调度系统多线程触发调度运行,确保调度精确执行,不被堵塞;
  26. 数据加密:调度中心和执行器之间的通讯进行数据加密,提升调度信息安全性;
  27. 邮件报警:任务失败时支持邮件报警,支持配置多邮件地址群发报警邮件;
  28. 推送 maven 中央仓库: 将会把最新稳定版推送到 maven 中央仓库, 方便用户接入和使用;
  29. 运行报表:支持实时查看运行数据,如任务数量、调度次数、执行器数量等;以及调度报表,如调度日期分布图,调度成功分布图等;
  30. 全异步:任务调度流程全异步化设计实现,如异步调度、异步运行、异步回调等,有效对密集调度进行流量削峰,理论上支持任意时长任务的运行;
  31. 跨语言:调度中心与执行器提供语言无关的 RESTful API 服务,第三方任意语言可据此对接调度中心或者实现执行器。除此之外,还提供了 “多任务模式”和“httpJobHandler”等其他跨语言方案;
  32. 国际化:调度中心支持国际化设置,提供中文、英文两种可选语言,默认为中文;
  33. 容器化:提供官方 docker 镜像,并实时更新推送 dockerhub,进一步实现产品开箱即用;
  34. 线程池隔离:调度线程池进行隔离拆分,慢任务自动降级进入”Slow”线程池,避免耗尽调度线程,提高系统稳定性;
  35. 用户管理:支持在线管理系统用户,存在管理员、普通用户两种角色;
  36. 权限控制:执行器维度进行权限控制,管理员拥有全量权限,普通用户需要分配执行器权限后才允许相关操作

引入

  1. XXL-JOB docker服务端部署成功访问 IP:PORT/xxl-job-admin 如下图所示(docker搭建步骤在快速上手-环境配置中)

image-20221216202130079

2.依赖

<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.3.1</version>
</dependency>
  1. 配置yml
# XXL-JOB 配置
xxl:
  job:
    admin:
      ### 调度中心部署根地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
      addresses: http://127.0.0.1:8080/xxl-job-admin
    executor:
      ### 执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
      appname: xxl-job-executor-bilibili
      ### 执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
      logpath: logs/xxl-job/jobhandler
    ### xxl-job, access token
    accessToken: juneqqq
  1. 配置类
/**
 * XXL-JOB 配置类
 */
@Configuration
@Slf4j
public class XxlJobConfig {

    @Value("${xxl.job.admin.addresses}")
    private String adminAddresses;

    @Value("${xxl.job.accessToken}")
    private String accessToken;

    @Value("${xxl.job.executor.appname}")
    private String appname;

    @Value("${xxl.job.executor.logpath}")
    private String logPath;

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        log.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
        xxlJobSpringExecutor.setAccessToken(accessToken);
        xxlJobSpringExecutor.setAppname(appname);
        xxlJobSpringExecutor.setLogPath(logPath);
        return xxlJobSpringExecutor;
    }
}

补充说明:需要手动在XXL-JOB web控制界面创建执行器(对应yml配置中的名称),服务启动后会自动注册,本机测试大概有5-10s注册延迟

  1. 数据库表
#
# XXL-JOB v2.4.0-SNAPSHOT
# Copyright (c) 2015-present, xuxueli.

CREATE database if NOT EXISTS `xxl_job` default character set utf8mb4 collate utf8mb4_unicode_ci;
use `xxl_job`;

SET NAMES utf8mb4;

CREATE TABLE `xxl_job_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `job_group` int(11) NOT NULL COMMENT '执行器主键ID',
  `job_desc` varchar(255) NOT NULL,
  `add_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `author` varchar(64) DEFAULT NULL COMMENT '作者',
  `alarm_email` varchar(255) DEFAULT NULL COMMENT '报警邮件',
  `schedule_type` varchar(50) NOT NULL DEFAULT 'NONE' COMMENT '调度类型',
  `schedule_conf` varchar(128) DEFAULT NULL COMMENT '调度配置,值含义取决于调度类型',
  `misfire_strategy` varchar(50) NOT NULL DEFAULT 'DO_NOTHING' COMMENT '调度过期策略',
  `executor_route_strategy` varchar(50) DEFAULT NULL COMMENT '执行器路由策略',
  `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler',
  `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数',
  `executor_block_strategy` varchar(50) DEFAULT NULL COMMENT '阻塞处理策略',
  `executor_timeout` int(11) NOT NULL DEFAULT '0' COMMENT '任务执行超时时间,单位秒',
  `executor_fail_retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败重试次数',
  `glue_type` varchar(50) NOT NULL COMMENT 'GLUE类型',
  `glue_source` mediumtext COMMENT 'GLUE源代码',
  `glue_remark` varchar(128) DEFAULT NULL COMMENT 'GLUE备注',
  `glue_updatetime` datetime DEFAULT NULL COMMENT 'GLUE更新时间',
  `child_jobid` varchar(255) DEFAULT NULL COMMENT '子任务ID,多个逗号分隔',
  `trigger_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '调度状态:0-停止,1-运行',
  `trigger_last_time` bigint(13) NOT NULL DEFAULT '0' COMMENT '上次调度时间',
  `trigger_next_time` bigint(13) NOT NULL DEFAULT '0' COMMENT '下次调度时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `xxl_job_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `job_group` int(11) NOT NULL COMMENT '执行器主键ID',
  `job_id` int(11) NOT NULL COMMENT '任务,主键ID',
  `executor_address` varchar(255) DEFAULT NULL COMMENT '执行器地址,本次执行的地址',
  `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler',
  `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数',
  `executor_sharding_param` varchar(20) DEFAULT NULL COMMENT '执行器任务分片参数,格式如 1/2',
  `executor_fail_retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败重试次数',
  `trigger_time` datetime DEFAULT NULL COMMENT '调度-时间',
  `trigger_code` int(11) NOT NULL COMMENT '调度-结果',
  `trigger_msg` text COMMENT '调度-日志',
  `handle_time` datetime DEFAULT NULL COMMENT '执行-时间',
  `handle_code` int(11) NOT NULL COMMENT '执行-状态',
  `handle_msg` text COMMENT '执行-日志',
  `alarm_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '告警状态:0-默认、1-无需告警、2-告警成功、3-告警失败',
  PRIMARY KEY (`id`),
  KEY `I_trigger_time` (`trigger_time`),
  KEY `I_handle_code` (`handle_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `xxl_job_log_report` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `trigger_day` datetime DEFAULT NULL COMMENT '调度-时间',
  `running_count` int(11) NOT NULL DEFAULT '0' COMMENT '运行中-日志数量',
  `suc_count` int(11) NOT NULL DEFAULT '0' COMMENT '执行成功-日志数量',
  `fail_count` int(11) NOT NULL DEFAULT '0' COMMENT '执行失败-日志数量',
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `i_trigger_day` (`trigger_day`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `xxl_job_logglue` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `job_id` int(11) NOT NULL COMMENT '任务,主键ID',
  `glue_type` varchar(50) DEFAULT NULL COMMENT 'GLUE类型',
  `glue_source` mediumtext COMMENT 'GLUE源代码',
  `glue_remark` varchar(128) NOT NULL COMMENT 'GLUE备注',
  `add_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `xxl_job_registry` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `registry_group` varchar(50) NOT NULL,
  `registry_key` varchar(255) NOT NULL,
  `registry_value` varchar(255) NOT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `i_g_k_v` (`registry_group`,`registry_key`,`registry_value`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `xxl_job_group` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `app_name` varchar(64) NOT NULL COMMENT '执行器AppName',
  `title` varchar(12) NOT NULL COMMENT '执行器名称',
  `address_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '执行器地址类型:0=自动注册、1=手动录入',
  `address_list` text COMMENT '执行器地址列表,多地址逗号分隔',
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `xxl_job_user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL COMMENT '账号',
  `password` varchar(50) NOT NULL COMMENT '密码',
  `role` tinyint(4) NOT NULL COMMENT '角色:0-普通用户、1-管理员',
  `permission` varchar(255) DEFAULT NULL COMMENT '权限:执行器ID列表,多个逗号分割',
  PRIMARY KEY (`id`),
  UNIQUE KEY `i_username` (`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `xxl_job_lock` (
  `lock_name` varchar(50) NOT NULL COMMENT '锁名称',
  PRIMARY KEY (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `xxl_job_group`(`id`, `app_name`, `title`, `address_type`, `address_list`, `update_time`) VALUES (1, 'xxl-job-executor-sample', '示例执行器', 0, NULL, '2018-11-03 22:21:31' );
INSERT INTO `xxl_job_info`(`id`, `job_group`, `job_desc`, `add_time`, `update_time`, `author`, `alarm_email`, `schedule_type`, `schedule_conf`, `misfire_strategy`, `executor_route_strategy`, `executor_handler`, `executor_param`, `executor_block_strategy`, `executor_timeout`, `executor_fail_retry_count`, `glue_type`, `glue_source`, `glue_remark`, `glue_updatetime`, `child_jobid`) VALUES (1, 1, '测试任务1', '2018-11-03 22:21:31', '2018-11-03 22:21:31', 'XXL', '', 'CRON', '0 0 0 * * ? *', 'DO_NOTHING', 'FIRST', 'demoJobHandler', '', 'SERIAL_EXECUTION', 0, 0, 'BEAN', '', 'GLUE代码初始化', '2018-11-03 22:21:31', '');
INSERT INTO `xxl_job_user`(`id`, `username`, `password`, `role`, `permission`) VALUES (1, 'admin', 'e10adc3949ba59abbe56e057f20f883e', 1, NULL);
INSERT INTO `xxl_job_lock` ( `lock_name`) VALUES ( 'schedule_lock');

commit;

项目优化

修改MinioClient源码实现前端直接分片上传

需求

相信接触过Minio的小伙伴清楚的知道,minio在上传文件的api中实现了分片上传的功能,当文件大于5m时,会自动采用分片的方式进行文件上传。

但这有一些不完美的地方;

  1. MinioClient提供的接口完全是一个黑盒,我们无法得知上传的分片后的序号,也就是说,完全无法得知上传过程中到底发生了什么,每上传一个分片,我们都需要自己去记录已上传分片的序号(假如需要自己纪录分片文件信息的话)。这将导致一个文件分片5个,那么同样还需要调用5次后端接口去记录这5个分片的信息。这无疑大大浪费了性能,且无法做到并发上传。

  2. 上传流程是 前端->后端->Minio Server。也就是说,是前端上传文件到后端,再由后端上传到minio。这跟我们用阿里云那些厂商的对象存储好像不太一样啊~因为我们对于阿里云来说,我们的后端程序就是阿里云对象存储的前端,而他们的分片上传流程是 前端->对象存储服务

所以,这里考虑如何实现像那些云厂商一样,直接前端直传后台,而且是分片的呢?

实现思路

MinioClient源码中关键的API
createMultipartUpload //创建分片上传,返回uploadId
getPresignedObjectUrl //创建文件预上传地址
listParts //获取uploadId下的所有分片文件
completeMultipartUpload //合并分片文件

以上四个方法均为不公开的,我们需要想办法重写源码开放以上几个核心方法。

  1. 新建PearlMinioClient extends MinioAsyncClient(较高一点的版本都改成了xxxAsyncxxx,方法均是异步的)
  2. 重新上述方法
/**
     * 创建分片上传请求
     *
     * @param bucketName       存储桶
     * @param region           区域
     * @param objectName       对象名
     * @param headers          消息头
     * @param extraQueryParams 额外查询参数
     */
    @SneakyThrows
    @Override
    public CreateMultipartUploadResponse createMultipartUpload(String bucketName, String region, String objectName, Multimap<String, String> headers, Multimap<String, String> extraQueryParams) {
        return super.createMultipartUploadAsync(bucketName, region, objectName, headers, extraQueryParams).get();
    }
/**
     * 查询分片数据
     *
     * @param bucketName       存储桶
     * @param region           区域
     * @param objectName       对象名
     * @param uploadId         上传ID
     * @param extraHeaders     额外消息头
     * @param extraQueryParams 额外查询参数
     */
    @SneakyThrows
    public ListPartsResponse listParts(String bucketName, String region, String objectName, Integer maxParts, Integer partNumberMarker, String uploadId, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InternalException {
        return super.listPartsAsync(bucketName, region, objectName, maxParts, partNumberMarker, uploadId, extraHeaders, extraQueryParams).get();
    }
/**
     * 完成分片上传,执行合并文件
     *
     * @param bucketName       存储桶
     * @param region           区域
     * @param objectName       对象名
     * @param uploadId         上传ID
     * @param parts            分片
     * @param extraHeaders     额外消息头
     * @param extraQueryParams 额外查询参数
     */
    @SneakyThrows
    @Override
    public ObjectWriteResponse completeMultipartUpload(String bucketName, String region, String objectName, String uploadId, Part[] parts, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) {
        return super.completeMultipartUploadAsync(bucketName, region, objectName, uploadId, parts, extraHeaders, extraQueryParams).get();
    }
最后包装一层工具类
/**
 * @author juneqqq
 * @version 1.0
 * @date 2022/4/12 10:21
 * @description minio 操作类
 **/
@Slf4j
@Component
public class MinioHelper {

    @Resource
    private PearlMinioClient client;

    @Resource
    public MinioProperties minioProperties;

    /**
     * 桶存在与否
     */
    public Boolean bucketExists(String bucketName) {
        boolean found;
        try {
            found = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()).get();
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return found;
    }


    /**
     * 上传单个文件
     */
    public FileUploadResponse uploadFile(MultipartFile multipartFile, String bucket, String hash) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InternalException {

        boolean found = bucketExists(bucket);
        if (!found) {
            log.debug("create bucket: [{}]", bucket);
            client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
        } else {
            log.debug("bucket '{}' already exists.", bucket);
        }

        try (InputStream inputStream = multipartFile.getInputStream()) {

            // 上传文件的名称
            // asdsad-xx.json
            String uploadName = hash + "-" + multipartFile.getOriginalFilename();

            // PutObjectOptions,上传配置(文件大小,内存中文件分片大小)
            PutObjectArgs putObjectOptions = PutObjectArgs.builder().bucket(bucket).object(uploadName).contentType(multipartFile.getContentType()).stream(inputStream, multipartFile.getSize(), -1).build();
            log.debug("prepare upload!");
            long l1 = System.currentTimeMillis();
            client.putObject(putObjectOptions).get();
            long l2 = System.currentTimeMillis();
            log.debug("file-size:" + multipartFile.getSize());
            log.debug("服务端->Minio端【上传耗时】:" + (l2 - l1));

            final String url = minioProperties.getEndpoint() + "/" + bucket + "/" + UriUtils.encode(uploadName, StandardCharsets.UTF_8);

            // 返回访问路径
            return FileUploadResponse.builder().uploadName(uploadName).url(url).realName(multipartFile.getOriginalFilename()).size(multipartFile.getSize()).bucket(bucket).build();
        } catch (ExecutionException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    public void removeFile(String objectName, String bucket) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        client.removeObject(RemoveObjectArgs.builder().bucket(bucket).object(objectName).build());
    }

    /**
     * HTTP文件下载
     */
    public void download(HttpServletResponse response, String hash, String bucket) throws Exception {
        InputStream in = null;
        try {
            //获取文件对象 stat原信息
            StatObjectResponse stat = client.statObject(StatObjectArgs.builder().bucket(bucket).object(hash).build()).get();
            response.setContentType(stat.contentType());
            response.setHeader("Content-disposition", "attachment;filename=down");
            in = client.getObject(GetObjectArgs.builder().bucket(bucket).object(hash).build()).get();
            IOUtils.copy(in, response.getOutputStream());
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    log.error(e.getMessage(), e);
                }
            }
        }
    }

    /**
     * 本地下载
     */
    public void download(File file, String objectName, String bucket) {

        try (
                InputStream in = client.getObject(GetObjectArgs.builder().bucket(bucket).object(objectName).build()).get();
                FileOutputStream fos = new FileOutputStream(file)) {
            byte[] buffer = new byte[1024 * 4];
            int read;
            while ((read = in.read(buffer)) != -1) {
                fos.write(buffer, 0, read);
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * @param multipartUploadCreate 自建参数
     * @return
     */
    public CreateMultipartUploadResponse uploadId(MultipartUploadCreate multipartUploadCreate) {
        try {
            return client.createMultipartUpload(multipartUploadCreate.getBucketName(), multipartUploadCreate.getRegion(), multipartUploadCreate.getObjectName(), multipartUploadCreate.getHeaders(), multipartUploadCreate.getExtraQueryParams());
        } catch (Exception e) {
            log.error("获取上传编号失败,异常信息:{}", e.getMessage());
            throw new BusinessException(ErrorCodeEnum.MINIO_FILE_IO_ERROR);
        }
    }

    /**
     * 合并分片
     *
     * @param multipartUploadCreate 传参DTO
     * @return ObjectWriteResponse vo
     */
    public ObjectWriteResponse completeMultipartUpload(MultipartUploadCreate multipartUploadCreate) {
        try {
            return client.completeMultipartUpload(multipartUploadCreate.getBucketName(), multipartUploadCreate.getRegion(), multipartUploadCreate.getObjectName(), multipartUploadCreate.getUploadId(), multipartUploadCreate.getParts(), multipartUploadCreate.getHeaders(), multipartUploadCreate.getExtraQueryParams());
        } catch (Exception e) {
            log.error("合并分片失败", e);
            throw new BusinessException(ErrorCodeEnum.MINIO_FILE_IO_ERROR);
        }
    }


    /**
     *
     */
    public ListPartsResponse listMultipart(MultipartUploadCreate multipartUploadCreate) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, ExecutionException, XmlParserException, InterruptedException, InternalException {
        return client.listParts(multipartUploadCreate.getBucketName(), multipartUploadCreate.getRegion(), multipartUploadCreate.getObjectName(), multipartUploadCreate.getMaxParts(), multipartUploadCreate.getPartNumberMarker(), multipartUploadCreate.getUploadId(), multipartUploadCreate.getHeaders(), multipartUploadCreate.getExtraQueryParams());
    }


    /**
     * 获取单个分片预上传地址
     */
    public String getPresignedObjectUrl(String bucketName, String objectName, Map<String, String> queryParams) {
        try {
            return client.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().method(Method.PUT).bucket(bucketName).object(objectName).expiry(60 * 60 * 24).extraQueryParams(queryParams).build());
        } catch (Exception e) {
            log.error("查询分片失败", e);
            throw new BusinessException(ErrorCodeEnum.MINIO_FILE_IO_ERROR);
        }
    }


    public FileUploadResponse uploadFile(File file, String bucket, String hash) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InternalException {
        boolean found = bucketExists(bucket);
        if (!found) {
            log.debug("create bucket: [{}]", bucket);
            client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
        } else {
            log.debug("bucket '{}' already exists.", bucket);
        }


        try (FileInputStream inputStream = new FileInputStream(file)) {

            // 上传文件的名称
            // asdsad-xx.json
            String uploadName = hash + "-" + file.getName();

            // PutObjectOptions,上传配置(文件大小,内存中文件分片大小)
            PutObjectArgs putObjectOptions = PutObjectArgs.builder().bucket(bucket).object(uploadName).contentType(MediaTypeFactory.getMediaType(file.getName()).orElse(MediaType.APPLICATION_OCTET_STREAM).toString()).stream(inputStream, file.length(), -1).build();
            log.debug("prepare upload!");
            long l1 = System.currentTimeMillis();
            client.putObject(putObjectOptions).get();
            long l2 = System.currentTimeMillis();
            log.debug("file-size:" + file.length());
            log.debug("服务端->Minio端【上传耗时】:" + (l2 - l1));

            final String url = minioProperties.getEndpoint() + "/" + bucket + "/" + UriUtils.encode(uploadName, StandardCharsets.UTF_8);

            // 返回访问路径
            return FileUploadResponse.builder().uploadName(uploadName).url(url).realName(file.getName()).size(file.length()).bucket(bucket).build();
        } catch (ExecutionException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

整体实现流程

前端->/multipart/create

// 返回请求创建的文件的状态
FILE_COMPLETELY_EXISTS   // 文件已完整存在
FILE_NEED_MERGE  				 // 文件分片全部存在,需要合并
FILE_PARTLY_EXISTS       // 文件部分存在,需要上传缺失的分片
FILE_NOT_EXISTS          // 文件完全不存在,需要重新上传
  1. FILE_COMPLETELY_EXISTS后台会附带返回fileId,去数据库查询文件数据即可
  2. FILE_NEED_MERGE提前返回成功标识,后台执行文件合并操作。这种情况本来就发生概率很小
  3. FILE_PARTLY_EXISTS前一次上传了部分的分片,那么此时上传后台会将缺失的分片集合返回给前端,由前端重新上传缺失部分
  4. FILE_NOT_EXISTS这是一次全新的上传。后台会向Minio先申请uploadId,再为每一个分片创建独立的上传地址,返回给前端

性能测试

本环境上传速度平均3M/s

预热操作是准备100个6M文件预先上传到minio,然后再开启自己的测试任务

改进前:

预热完成

16M->7.1s

200M->140s

1G->785s

改进后:

16M

image-20221118093211358

200M

image-20221118094046288

1G

image-20221118092822757

使用装饰者模式解决表单形式传参的 XSS 攻击

XSS 攻击定义

跨站脚本攻击(XSS),是最普遍的 Web 应用安全漏洞。能够使得攻击者嵌入恶意脚本代码到正常用户会访问到的页面中,当正常用户访问该页面时,则可导致嵌入的恶意脚本代码的执行,从而达到恶意攻击用户的目的。

例如,在项目中,如果没有预防 XSS 攻击的话。恶意用户进入到我们评论区,发表如下评论:

<script>
    // 获取当前登录用户的认证 token
    token = localStorage.getItem('Authorization');
</script>

当其他正常用户登录成功进入到评论区后,会自动执行上述的 javascript 脚本,自己的登录 token 会被发送到攻击者的服务器上。攻击者拿到该 token 后即可利用该 token 来冒充正常用户进行一系列例如资金转账等危险操作。

攻击者还可以利用该漏洞在我们系统中插入恶意内容(例如广告)、重定向用户(重定向到黄赌毒网站)等。

注:人们经常将跨站脚本攻击(Cross Site Scripting)缩写为 CSS,但这会与层叠样式表(Cascading Style Sheets,CSS)的缩写混淆。因此,有人将跨站脚本攻击缩写为 XSS。

装饰器模式定义:装饰器模式 | 菜鸟教程 (runoob.com)

装饰者可以在被装饰者的行为前面与/或后面加上自己的行为,甚至将被装饰者的行为整个取代掉,而达到特定的目的。

Spring MVC 是通过 HttpServletRequest 的 getParameterValues 方法来获取用户端的请求参数并绑定到我们 @RequestMapping 方法定义的对象上。所以我们可以装饰 HttpServletRequest 对象,在 getParameterValues 方法里加上自己的行为(对请求参数值里面的特殊字符进行转义)来解决 XSS 攻击。

由于 Servlet Api 提供了 HttpServletRequest 接口的便捷实现 HttpServletRequestWrapper 类,该类已经实现了装饰者模式,我们直接继承该类并重写里面的 getParameterValues 方法即可。

实现步骤

  1. 新建 XssHttpServletRequestWrapper 装饰者类继承 HttpServletRequestWrapper 类,并重写 getParameterValues 方法,对里面字符串的特殊字符进行转义:
/**
 * XSS 过滤处理
 */
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {

    private static final Map<String, String> REPLACE_RULE = new HashMap<>();

    static {
        // 转移HTML标签的必要两个字符,这样前台就不可能当做脚本解析
        REPLACE_RULE.put("<", "&lt;");
        REPLACE_RULE.put(">", "&gt;");
    }

    public XssHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    @Override
    public String[] getParameterValues(String name) {
        String[] values = super.getParameterValues(name);
        if (values != null) {
            int length = values.length;
            String[] escapeValues = new String[length];
            for (int i = 0; i < length; i++) {
                escapeValues[i] = values[i];
                int index = i;
                REPLACE_RULE.forEach(
                    (k, v) -> escapeValues[index] = escapeValues[index].replaceAll(k, v));
            }
            return escapeValues;
        }
        return new String[0];
    }
}

  1. 新建 XssFilter 过滤器,使用 XssHttpServletRequestWrapper 装饰者对象替换掉 HttpServletRequest 被装饰者对象:
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) servletRequest);
    filterChain.doFilter(xssRequest, servletResponse);
}

一行代码解决 JSON 形式传参的 XSS 攻击

问题

前后端分离项目,对于 POST 和 PUT 类型的请求方法,后端基本都是通过 @RequestBody 注解接收 application/json 格式的请求数据,所以以前通过过滤器 + 装饰器 HttpServletRequestWrapper 来解决 XSS 攻击的方式并不适用。在 Spring Boot 中,我们可以通过配置全局的 Json 反序列化器转义特殊字符来解决 XSS 攻击。

实现代码

/**
 * JSON 全局反序列化器:防止XSS攻击-对象属性注入脚本
 */
@JsonComponent
public class GlobalJsonDeserializer {
    private GlobalJsonDeserializer() {
    }
    public static class StringDeserializer extends JsonDeserializer<String> {

        @Override
        public String deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
      // 实际代码就这一行
            return jsonParser.getValueAsString()
                    .replace("<", "&lt;")
                    .replace(">", "&gt;");
        }
    }
}

使用CompletableFuture优化接口响应速度

需求

随着系统接口数量越来越多,系统性能问题逐渐暴露出来,系统开发的主要目的也应该将重心放到优化上来。Future接口在Java5中被引入,设计初衷是对将来某个时刻会产生的结果进行建模。它建模了一种异步运算,返回一个执行结果的引用,当运算结束后,这个引用被返回给调用方。在Future中触发那些潜在耗时的操作完成。而在Java 8中, 新增加了一个包含50个方法左右的类: CompletableFuture,结合了Future的优点,提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,提供了函数式编程的能力,可以通过回调的方式处理计算结果,并且提供了转换和组合CompletableFuture的方法。

我们的大多数接口都呈现以下形式:

  1. 多个执行单元的串行请求

输入图片说明

  1. 多个执行单元的并行请求

输入图片说明

  1. 阻塞等待,串行的后面跟多个并行

输入图片说明

  1. 阻塞等待,多个并行的执行完毕后才执行某个

输入图片说明

  1. 串并行相互依赖

输入图片说明

  1. 复杂场景

输入图片说明

实现思路

在获取用户权限处有这么一个接口:接口内需要调用三个无关联的接口,那么这里就诞生了CompletableFuture的基础用法

public UserAuthorities getUserAuthorities(Long userId){
  UserAuthorities userAuthorities = new UserAuthorities();
  CompletableFuture<Set<Long>> c1 = CompletableFuture.supplyAsync(() -> {
    List<UserRole> userRoleList = userRoleService.getUserRoleByUserId(userId);
    // 抽取roleIds
    return userRoleList.stream().map(UserRole::getRoleId).collect(Collectors.toSet());
    // 抽取roleIds
  }, threadPoolExecutor);
  CompletableFuture<Void> c2 = c1.thenAcceptAsync((roleIdSet) -> {
    //  操作权限
    userAuthorities.setRoleElementOperationList(authRoleService.getRoleElementOperationsByRoleIds(roleIdSet));
  });
  CompletableFuture<Void> c3 = c1.thenAcceptAsync((roleIdSet) -> {
    //  菜单权限
    userAuthorities.setRoleMenuList(authRoleService.getAuthRoleMenusByRoleIds(roleIdSet));
  });
  try {
    CompletableFuture.allOf(c1, c2, c3).get();
  } catch (InterruptedException | ExecutionException e) {
    throw new RuntimeException(e);
  }
  return userAuthorities;
}

使用XXL-JOB优化ElasticSearch数据同步任务

  1. xxl-job handler
/**
 * 主要负责es数据的同步任务,一般在凌晨定时执行,全量更新es
 * @author june
 */
@Component
@Slf4j
public class ElasticSearchTask {
    @Resource
    ElasticsearchClient elasticsearchClient;
    @Resource
    private VideoService videoService;
    @Resource
    private UserService userService;

    /**
     * 每天凌晨做一次全量数据同步
     */
    @XxlJob("elasticsearch-update-handler")
    public ReturnT<String> elasticsearchUpdateHandler() {
        try {
            updateEsVideo();
            updateEsUserInfo();
        } catch (Exception e) {
            log.warn("xxl-job任务出错,错误信息:" + e.getMessage());
            return ReturnT.FAIL;
        }
        return ReturnT.SUCCESS;
    }

    private void updateEsUserInfo() {
        long l1 = System.currentTimeMillis();
        try {
            int size = 1000;
            // 从数据库查size条数据,拼接发往es
            for (int i = 0; ; i++) {
                List<EsUserInfoDto> esUserInfoDtoList = userService.selectBatchEsUserInfoDto(i, size);
                if (Collections.isEmpty(esUserInfoDtoList)) break;
                BulkRequest.Builder br = new BulkRequest.Builder();
                for (EsUserInfoDto euid : esUserInfoDtoList) {
                    br.operations(op -> op
                            .index(idx -> idx
                                    .index(UserInfoIndex.NAME)
                                    .id(String.valueOf(euid.getId()))
                                    .document(euid)
                            )
                    ).timeout(Time.of(t -> t.time("30s")));
                }
                BulkResponse result = elasticsearchClient.bulk(br.build());
                // Log errors, if any
                if (result.errors()) {
                    log.error("UserInfo Batch save to es error");
                    for (BulkResponseItem item : result.items()) {
                        if (item.error() != null) {
                            log.error(item.error().reason());
                        }
                    }
                }
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
        log.debug("updateEsUserInfo 执行耗时:{}", System.currentTimeMillis() - l1);
    }

    private void updateEsVideo() {
        try {
            int size = 1000;
            // 从数据库查size条数据,拼接发往es
            for (int i = 0; ; i++) {
                List<EsVideoDto> esVideoDtoList = videoService.selectBatchEsVideoDto(i, size);
                if (Collections.isEmpty(esVideoDtoList)) break;
                BulkRequest.Builder br = new BulkRequest.Builder();
                for (EsVideoDto evd : esVideoDtoList) {
                    br.operations(op -> op
                            .index(idx -> idx
                                    .index(VideoIndex.NAME)
                                    .id(String.valueOf(evd.getId()))
                                    .document(evd)
                            )
                    ).timeout(Time.of(t -> t.time("30s")));
                }

                BulkResponse result = elasticsearchClient.bulk(br.build());
                // Log errors, if any
                if (result.errors()) {
                    log.error("Video Batch save to es error");
                    for (BulkResponseItem item : result.items()) {
                        if (item.error() != null) {
                            log.error(item.error().reason());
                        }
                    }
                }
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
    }

}
  1. 新增xxl-job配置项

image-20221220123254126

使用 Sentinel 实现接口防刷和限流

问题

novel 作为一个互联网系统,经常会遇到非法爬虫(例如,盗版小说网站)来爬取我们系统的小说数据,这种爬虫行为有时会高达每秒几百甚至上千次访问。防刷的目的是为了限制这些爬虫请求我们接口的频率,如果我们不做接口防刷限制的话,我们系统很容易就会被爬虫干倒。

限流的目的是在流量高峰期间,根据我们系统的承受能力,限制同时请求的数量,保证多余的请求会阻塞一段时间再处理,不简单粗暴的直接返回错误信息让客户端重试,同时又能起到流量削峰的作用。

很多时候,我们都是尽量将请求拦截在系统上游,比如在反向代理层通过 Nginx + Lua + Redis 来实现限流功能,这个在后面部署篇章里面会详细地讲解如何实现。如果我们系统还没有使用类似于 Nginx 一样的反向代理,又或者我们想实现更复杂的流量控制,想要一个人性化的控制面板来动态限流和实时监控,那么我们可以使用阿里巴巴开源的高可用流控防护组件 Sentinel 来实现。

Sentinel 介绍

Sentinel 是一个面向云原生微服务的高可用流控防护组件,以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。

Sentinel 有两个重要的概念,资源规则

资源是 Sentinel 的关键概念。它可以是 Java 应用程序中的任何内容,例如,由应用程序提供的服务,或由应用程序调用的其它应用提供的服务,甚至可以是一段代码。只要通过 Sentinel API 定义的代码,就是资源,能够被 Sentinel 保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。

规则是围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。

Sentinel 具有以下特征:

  • 丰富的应用场景:Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、集群流量控制、实时熔断下游不可用应用等。
  • 完备的实时监控:Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。
  • 广泛的开源生态:Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Apache Dubbo、gRPC、Quarkus 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。同时 Sentinel 提供 Java/Go/C++ 等多语言的原生实现。
  • 完善的 SPI 扩展机制:Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快速地定制逻辑。例如定制规则管理、适配动态数据源等。

Sentinel 分为核心库控制台两部分,核心库不依赖控制台,但是结合控制台可以取得最好的效果:

  • 核心库(Java 客户端)不依赖任何框架/库,能够运行于所有 Java 运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持。
  • 控制台(Dashboard)基于 Spring Boot 开发,打包后可以直接运行,不需要额外的 Tomcat 等应用容器。

实现

  1. 引入 Sentinel 相关依赖:
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-core</artifactId>
    <version>${sentinel.version}</version>
</dependency>
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-parameter-flow-control</artifactId>
    <version>${sentinel.version}</version>
</dependency>
  1. 注册一个全局的拦截器拦截所有的请求:
// 流量限制拦截器
registry.addInterceptor(flowLimitInterceptor)
        .addPathPatterns("/**")
        .order(0);
  1. 拦截器中定义资源和规则,资源在preHandle方法中定义,为所有请求的入口,接口限流规则接口防刷规则通过static 代码块在类加载时初始化:
/**
 * 流量限制 拦截器:实现接口防刷和限流
 *
 */
@Component
@Slf4j
public class FlowLimitInterceptor implements HandlerInterceptor {

    @Resource
    ObjectMapper objectMapper;

    /**
     *  项目所有的资源
     */
    private static final String BILIBILI_RESOURCE = "bilibiliResource";

    static {
        List<FlowRule> rules = new ArrayList<>();
        // 1. 所有的请求,限制每秒最多只能通过 2000 个,超出限制匀速排队
        FlowRule rule1 = new FlowRule();
        rule1.setResource(BILIBILI_RESOURCE);
        rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
        // Set limit QPS to 2000.
        rule1.setCount(2000);
        rule1.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER);
        rules.add(rule1);
        FlowRuleManager.loadRules(rules);
        // 2. 每个 IP 每秒最多只能通过 50 个,超出限制直接拒绝
        ParamFlowRule rule2 = new ParamFlowRule(BILIBILI_RESOURCE)
                .setParamIdx(0)
                .setCount(50);
        // 3. 所有的请求,限制每个 IP 每分钟最多只能通过 1000 个,超出限制直接拒绝
        ParamFlowRule rule3 = new ParamFlowRule(BILIBILI_RESOURCE)
                .setParamIdx(0)
                .setCount(1000)
                .setDurationInSec(60);
        ParamFlowRuleManager.loadRules(Arrays.asList(rule2, rule3));
    }

    @Override
    @SuppressWarnings("all")
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        String ip = IpUtil.getIP(request);
        Entry entry = null;
        try {
            // 若需要配置例外项,则传入的参数只支持基本类型。
            // EntryType 代表流量类型,其中系统规则只对 IN 类型的埋点生效
            // count 大多数情况都填 1,代表统计为一次调用。
            entry = SphU.entry(BILIBILI_RESOURCE, EntryType.IN, 1, ip);
            // Your logic here.
            return HandlerInterceptor.super.preHandle(request, response, handler);
        } catch (BlockException ex) {
            // Handle request rejection.
            log.info("IP:{}被限流了!", ip);
            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter()
                    .write(objectMapper.writeValueAsString(R.fail(ErrorCodeEnum.USER_REQ_MANY)));
        } finally {
            // 注意:exit 的时候也一定要带上对应的参数,否则可能会有统计错误。
            if (entry != null) {
                entry.exit(1, ip);
            }
        }
        return false;
    }
}

posted @ 2022-12-20 18:20  June_R  阅读(547)  评论(0)    收藏  举报