📕开源风云系列
本篇文字量巨大,甚至在发表编辑之时造成编辑器卡顿,哈哈,最近在忙人生的另一项规划,文章更新就逐渐缓慢了,希望我们都逐渐走向自己的道路呀!
- 🍊本系列将从开源名将若依出发,探究优质开源项目脚手架汲取编程之道。
- 🍉从不分离版本开写到前后端分离版,再到微服务版本,乃至其中好玩的一系列增强Plus操作。
- 🍈希望你具备如下技术栈 :
- 🍎Spring
- 🍍SpringMVC
- 🍐Mybatis/Mybatis-plus
- 🍅Thymeleaf
- 🥝SpringBoot
- 🍓Shiro
- 🍏SpringSecurity
- 🍌SpringCloud
- 🍒云服务器相关知识
- 本篇是微服务版第一篇
- 文章同时同步到我的个人站点🍊欢迎来访! :
目录
1、RuoYi-Clouid
我这里将部分服务部署在云上服务器
1.1、导入数据库
- 下载源码,解压,将
ruoyi-ui
放到webstrom中 - 将其他目录加载进 IDEA 中,等待IDEA加载依赖
- 在宝塔创建数据库
kuangstudy_cloud
,访问权限选择所有人。再创建数据库kuangstudy_config
-
使用Navicat连接数据库
kuangstudy_cloud
,导入数据脚本ry_2024xxxx.sql
(必须),quartz.sql
(可选) -
使用Navicat连接数据库
kuangstudy_config
,导入数据脚本ry_config_2024xxxx.sql
(必须)
[!WARNING]
注意: 在执行
ry_config_2024xxxx.sql
时候,需要提前进入 sql ,将ry-config
替换为kuangstudy-config
如果你数据库起名为
ry-config
,则不需要执行这一步。
1.2、Docker中运行Nacos
我们要首先安装Docker,Docker的安装可以使用命令也可以使用宝塔一键安装,我这边使用宝塔进行安装。
[!NOTE]
这里我贴上我自己的Docker笔记,里面有相关的安装命令,非常详细。
- 在宝塔后台安装Docker,点击Docker - 立即安装 - 这里我选择阿里云镜像安装
- 安装完成后设置加速URL,我选择的是阿里云镜像加速站,然后重启Docker
或者也可以参考教程贴设置自己的容器加速:如何在宝塔面板更换Docker加速站
只是我这边发现设置了不回显,执行命令
cat /etc/docker/daemon.json
也无此文件,说明宝塔面板的加速URL的设置没生效,所以我是直接更换了镜像加速
安装成功后,在服务器上执行docker -v
即可看到安装的版本
- 宝塔面板的应用商店只有个别的镜像,我没搜到nacos镜像,所以还是得命令执行拉取nacos镜像
bash
docker pull nacos/nacos-server
- 运行nacos容器
bash
docker run -d \
--name nacos \
-e PREFER_HOST_MODE=hostname \
-e MODE=standalone \
-e SPRING_DATASOURCE_PLATFORM=mysql \
-e MYSQL_SERVICE_HOST=49.232.28.14 \
-e MYSQL_SERVICE_PORT=3306 \
-e MYSQL_SERVICE_USER=kuang_config \
-e MYSQL_SERVICE_PASSWORD=123456 \
-e MYSQL_SERVICE_DB_NAME=kuangstudy_config \
-e JVM_XMS=256m \
-e JVM_XMX=256m \
--network=host \
nacos/nacos-server
docker run
命令用来在Docker中启动一个Nacos服务器容器。让我们逐个解析参数和它们的作用:
-d
:以守护进程(daemon)模式运行容器,这意味着容器将在后台运行。--name nacos
:为容器命名,这里命名为nacos
。-e
:设置环境变量。以下是一些具体的环境变量及其作用:
PREFER_HOST_MODE=hostname
:指示Nacos优先使用主机名而不是IP地址进行网络通信。MODE=standalone
:指定Nacos运行模式为独立模式,即单机模式。SPRING_DATASOURCE_PLATFORM=mysql
:告诉Nacos数据源平台是MySQL。MYSQL_SERVICE_HOST=49.232.28.14
:MySQL服务的主机地址。MYSQL_SERVICE_PORT=3306
:MySQL服务的端口。MYSQL_SERVICE_USER=kuang_config
:用于连接MySQL数据库的用户名。MYSQL_SERVICE_PASSWORD=123456
:连接MySQL数据库的密码。MYSQL_SERVICE_DB_NAME=kuangstudy_config
:要使用的MySQL数据库名称。JVM_XMS=256m
:设置JVM初始堆内存大小为256MB。JVM_XMX=256m
:设置JVM最大堆内存大小为256MB。--network=host
:使用宿主机的网络模式,这将使容器共享宿主机的网络栈,因此容器将直接使用宿主机的网络接口。nacos/nacos-server
:指定要运行的Docker镜像,这里是Nacos服务器的官方镜像。此外,使用
--network=host
可能会带来安全风险,因为它允许容器直接访问宿主机的网络,这可能会暴露宿主机上的其他服务。在生产环境中,通常建议使用更安全的网络模式,如桥接网络或用户定义的网络。
在宝塔面板可以点击容器,看到我们的nacos容器正在运行。
- 访问:
http://服务器ip:8848/nacos
,默认账号密码都是nacos
[!NOTE]
注意:记得宝塔面板和云服务器开启8848防火墙端口呦!官方文档说开8848和9848就可以满足大多数场景下的网络配置需求。登录成功后就可以看到如下页面,而表格中的数据就是我们导入的 config 配置。
1.3、运行网关Gateway模块
- RuoYiGatewayApplication (网关模块 必须)
- RuoYiAuthApplication (认证模块 必须)
- RuoYiSystemApplication (系统模块 必须)
- RuoYiMonitorApplication (监控中心 可选)
- RuoYiGenApplication (代码生成 可选)
- RuoYiJobApplication (定时任务 可选)
- RuoYFileApplication (文件服务 可选)
在真正部署起来后,前端所有的API请求都会通过Nginx,Nginx会将请求转发到网关模块,也就是 8080 端口,之后由后端的网关模块再去进行一个转发。
-
修改
ruoyi-gateway
模块下的bootstrap.yml
只需要更改三个地方的ip,服务注册地址、配置中心地址、nacos配置持久化,控制台地址ip不需要改动
yaml
# Tomcat
server:
port: 8080
# Spring
spring:
application:
# 应用名称:向注册中心注册的时候用的名称
name: ruoyi-gateway
profiles:
# 环境配置 指定了当前环境配置为 dev(开发环境)
active: dev
cloud:
nacos:
# discovery 配置用于服务发现,指定了 Nacos 服务器地址
discovery:
# 服务注册地址
server-addr: 49.232.28.14:8848
# config 配置用于从 Nacos 获取配置信息,同样指定了 Nacos 地址,并且说明了配置文件的格式为 .yml
config:
# 配置中心地址
server-addr: 49.232.28.14:8848
# 配置文件格式
file-extension: yml
# 共享配置:定义了要从 Nacos 加载的共享配置文件,使用了 ${} 占位符来动态引用环境变量,即在开发环境下加载名为 application-dev.yml 的配置文件。
shared-configs:
- application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
# 用于流量控制、熔断降级和系统保护的应用组件sentinel
sentinel:
# 取消控制台懒加载
eager: true
transport:
# 指定 Sentinel 控制台地址
dashboard: 127.0.0.1:8718
# nacos配置持久化
datasource:
# ds1 是一个指向 Nacos 的数据源,用于存储和读取 Sentinel 的规则配置
ds1:
nacos:
server-addr: 49.232.28.14:8848
# dataId 和 groupId 分别表示规则在 Nacos 中的标识和分组
dataId: sentinel-ruoyi-gateway
groupId: DEFAULT_GROUP
# data-type 和 rule-type 指定了规则类型为 JSON 格式和网关流量控制规则
data-type: json
rule-type: gw-flow
共享配置就是不止网关模块,其他模块也在用的配置文件,我们称为共享配置。一般情况下这个共享配置就是
application-dev.yml
我们点击详情,可以进去查看
yaml
spring:
# 排除Druid 数据源,因为若依自己重写了多数据源
autoconfigure:
exclude: com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure
# 指定了 Spring MVC 使用 Ant 风格的路径匹配器(AntPathMatcher)作为请求映射策略
mvc:
pathmatch:
matching-strategy: ant_path_matcher
# feign 配置:
feign:
# 启用 Sentinel 与 Feign 的集成,Sentinel 是一个开源的系统保护框架,可以用于流量控制、熔断降级等场景
sentinel:
enabled: true
# 使用 OkHttp 作为 Feign 的 HTTP 客户端,而不是 Apache HttpClient
okhttp:
enabled: true
httpclient:
enabled: false
# 设置 Feign 客户端的连接超时时间为 10 秒,读取超时时间也为 10 秒
client:
config:
default:
connectTimeout: 10000
readTimeout: 10000
# 启用了 Feign 请求和响应的压缩,请求压缩最小大小为 8KB。这有助于减少网络传输的数据量,提高传输效率
compression:
request:
enabled: true
min-request-size: 8192
response:
enabled: true
# 暴露监控端点
# Actuator 提供了一系列的监控和管理端点,如健康检查、度量指标、审计事件等。将 include 设置为 '*' 意味着所有端点都可以通过 Web 访问,这对于开发和测试非常有用,但在生产环境中可能需要更严格的访问控制。
management:
endpoints:
web:
exposure:
include: '*'
1.3.1、修改ruoyi-gateway-dev.yml
- 修改
ruoyi-gateway-dev.yml
,修改 redis 配置
yaml
spring:
redis:
# 主机地址
host: 49.232.28.14
# 端口
port: 6379
# 密码
password: xxxx
database: 10
# 配置了 Spring Cloud Gateway 的服务发现定位器,使其能够从注册中心Nacos发现并路由到服务实例
cloud:
gateway:
discovery:
locator:
# 服务 ID 将被转换为小写
lowerCaseServiceId: true
# 启用这个特性
enabled: true
# 认证中心:每个 - 开头的条目定义了一个路由规则,包括路由id、目标uri、路由条件、路由过滤器
routes:
# ruoyi-auth路由将所有以 /auth/ 开始的请求代理到名为 ruoyi-auth 的服务,并且应用了三个过滤器:缓存请求、验证码验证和前缀剥离
- id: ruoyi-auth
uri: lb://ruoyi-auth
predicates:
- Path=/auth/**
filters:
# 验证码处理
- CacheRequestFilter
- ValidateCodeFilter
- StripPrefix=1
# 代码生成
- id: ruoyi-gen
uri: lb://ruoyi-gen
predicates:
- Path=/code/**
filters:
- StripPrefix=1
# 定时任务
- id: ruoyi-job
uri: lb://ruoyi-job
predicates:
- Path=/schedule/**
filters:
- StripPrefix=1
# 系统模块
- id: ruoyi-system
uri: lb://ruoyi-system
predicates:
- Path=/system/**
filters:
- StripPrefix=1
# 文件服务
- id: ruoyi-file
uri: lb://ruoyi-file
predicates:
- Path=/file/**
filters:
- StripPrefix=1
# 安全配置
security:
# 验证码开启
captcha:
enabled: true
type: math
# 防止XSS攻击
xss:
enabled: true
# 排除 url
excludeUrls:
- /system/notice
# 不校验白名单:不需要身份验证的 URL 白名单
ignore:
whites:
- /auth/logout
- /auth/login
- /auth/register
- /*/v2/api-docs
- /csrf
1.3.2、启动RuoYiGatewayApplication.java
启动RuoYiGatewayApplication.java
的 main 方法即可。在Nacos中的服务列表可以看到服务,则说明启动成功
1.3.3、启动时控制台细节
启动时,我们可以从打印控制台看到如下信息:
bash
21:12:49.954 [main] INFO c.a.c.n.r.NacosContextRefresher - [registerNacosListener,129] - [Nacos Config] Listening config: dataId=ruoyi-gateway, group=DEFAULT_GROUP
21:12:49.955 [main] INFO c.a.c.n.r.NacosContextRefresher - [registerNacosListener,129] - [Nacos Config] Listening config: dataId=ruoyi-gateway.yml, group=DEFAULT_GROUP
21:12:49.955 [main] INFO c.a.c.n.r.NacosContextRefresher - [registerNacosListener,129] - [Nacos Config] Listening config: dataId=ruoyi-gateway-dev.yml, group=DEFAULT_GROUP
(♥◠‿◠)ノ゙ 若依网关启动成功 ლ(´ڡ`ლ)゙
.-------. ____ __
| _ _ \ \ \ / /
| ( ' ) | \ _. / '
|(_ o _) / _( )_ .'
| (_,_).' __ ___(_ o _)'
| |\ \ | || |(_,_)'
| | \ `' /| `-' /
| | \ / \ /
''-' `'-' `-..-'
配置中心的数据实时发布,微服务模块可以收到配置中心的数据,那么网关模块可以收到哪些配置信息呢?仔细看如下打印,有三个配置文件可以收到:
bash
Listening config: dataId=ruoyi-gateway, group=DEFAULT_GROUP
Listening config: dataId=ruoyi-gateway.yml, group=DEFAULT_GROUP
Listening config: dataId=ruoyi-gateway-dev.yml, group=DEFAULT_GROUP
说明网关模块在获取dataID为ruoyi-gateway
,group为DEFAULT_GROUP
的配置信息,可以理解为横纵坐标,通过横纵坐标,可以找到网关模块可以获取到如下配置信息:
在这里可以发现,若依很细节,其实如果我们不写后缀 .yml,其实网关模块也是可以收到的哈哈哈
1.4、运行认证Auth模块
- 修改修改
ruoyi-auth
模块下的bootstrap.yml
- 修改nacos的
ruoyi-auth-dev.yml
yaml
spring:
redis:
host: 49.232.28.14
port: 6379
password: xxxx
database: 10
- 启动
RuoYiAuthApplication.java
的 main 方法即可。在Nacos中的服务列表可以看到服务,则说明启动成功
同理,通过控制台可以看到认证模块监听的配置文件如下
1.5、运行系统System模块
- 修改
ruoyi-modules/ruoyi-system/src/main/resources/bootstrap.yml
- 修改
ruoyi-system-dev.yml
- 修改 redis 的host、账号密码、包括 database
- 修改主库数据源的链接、账号密码
yaml
# spring配置
spring:
redis:
host: 49.232.28.14
port: 6379
password: First123.
database: 10
datasource:
# druid 服务监控的账号和密码,也就是druid自己的控制台
druid:
stat-view-servlet:
enabled: true
loginUsername: admin
loginPassword: 123456
# 动态数据源
dynamic:
druid:
# 初始创建的连接数
initial-size: 5
# 最小空闲连接数
min-idle: 5
# 最大活动连接数
maxActive: 20
# 获取连接的最大等待时间,单位毫秒
maxWait: 60000
# 建立连接的超时时间,单位毫秒
connectTimeout: 30000
# 读取数据的超时时间,单位毫秒
socketTimeout: 60000
# 连接空闲检查的时间间隔,单位毫秒
timeBetweenEvictionRunsMillis: 60000
# 连接空闲多久后可被驱逐,单位毫秒
minEvictableIdleTimeMillis: 300000
# 用于验证连接有效性的SQL语句
validationQuery: SELECT 1 FROM DUAL
# 当连接空闲时进行验证
testWhileIdle: true
# 当从连接池获取连接时进行验证
testOnBorrow: false
# 当连接返回连接池时进行验证
testOnReturn: false
# 是否缓存PreparedStatement
poolPreparedStatements: true
# 每个连接缓存的PreparedStatement数量
maxPoolPreparedStatementPerConnectionSize: 20
# Druid提供的过滤器,例如统计和日志记录
filters: stat,slf4j
# Druid的连接属性,包括合并SQL和慢SQL的阈值
connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
datasource:
# 主库数据源
master:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://49.232.28.14:3306/kuangstudy_cloud?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: kuangstudy_cloud
password: 123456
# 从库数据源
# slave:
# username:
# password:
# url:
# driver-class-name:
# mybatis配置
mybatis:
# 搜索指定包别名
typeAliasesPackage: com.ruoyi.system
# 配置mapper的扫描,找到所有的mapper.xml映射文件
mapperLocations: classpath:mapper/**/*.xml
# swagger配置
swagger:
title: 系统模块接口文档
license: Powered By ruoyi
licenseUrl: https://ruoyi.vip
解释之前为什么要排除Druid,是因为我们自己写了动态数据源 dynamic,然后用了Druid,又分为主从库
- 运行
RuoYiSystemApplication.java
的main方法
同理,通过控制台可以看到系统模块监听的配置文件如下
1.6、运行前端
这样必须运行的模块都运行完成了,其他模块暂时可以不用管,我们接下来运行前端
- 拉取依赖
bash
npm i
# 或者(推荐)
pnpm i
- 在 package.json 中运行 dev
大功告成!我们这个时候可以不登陆,直接去看redis中的数据!
这个就跟前后端分离版本串起来了!满满的熟悉感吧哈哈哈!之后就可以奔放玩啦!
1.7、前后端目录结构
1.7.1、后端结构
微服务版本若依没有用Spring Security 框架,而是自己写了一套安全框架。
1.7.2、前端结构
1.7.3、核心技术
[!NOTE]
- 前端技术栈 ES6、vue、vuex、vue-router、vue-cli、axios、element-ui
- 后端技术栈 Spring Boot、Spring Cloud & Alibaba、Nacos、Sentinel
2、服务网关
2.1、微服务架构图
🔥🔥🔥浏览器发生API的调用,调用的时候可以使用Nginx进行负载均衡,进入网关gateway集群,例如如下配置:网关根据我们的url路径,判断进入哪个微服务,比如:访问的路径是/auth/**
打头的,那么网关就将请求负载均衡送入lb://ruoyi-auth
微服务,lb就是负载均衡,并且对请求进行过滤和处理,需要注意的是StripPrefix=1
,会把请求的/auth
前缀去掉,那么请求的就会是/**
后面的url了。
yaml
spring:
cloud:
routes:
# 认证中心
- id: ruoyi-auth
uri: lb://ruoyi-auth
predicates:
- Path=/auth/**
filters:
# 验证码处理
- CacheRequestFilter
- ValidateCodeFilter
- StripPrefix=1
像上面配置的 filters 过滤器有三个,分别是验证码、校验码、前缀去除过滤器。其中前缀去除StripPrefix过滤器是SpringCloud自带的,SpringCloud自带的过滤器有很多,进入查看
- 并且这种配置在一个路由下的 filters 是局部过滤器,只对这个路由下的请求生效
2.2、路由分发具体策略
yaml
spring:
redis:
host: 49.232.28.14
port: 6379
password: xxx
database: 10
cloud:
gateway:
discovery:
locator:
lowerCaseServiceId: true
enabled: true
routes:
# 认证中心
- id: ruoyi-auth
uri: lb://ruoyi-auth
predicates:
- Path=/auth/**
filters:
# 验证码处理
- CacheRequestFilter
- ValidateCodeFilter
- StripPrefix=1
# 代码生成
- id: ruoyi-gen
uri: lb://ruoyi-gen
predicates:
- Path=/code/**
filters:
- StripPrefix=1
# 定时任务
- id: ruoyi-job
uri: lb://ruoyi-job
predicates:
- Path=/schedule/**
filters:
- StripPrefix=1
# 系统模块
- id: ruoyi-system
uri: lb://ruoyi-system
predicates:
- Path=/system/**
filters:
- StripPrefix=1
# 文件服务
- id: ruoyi-file
uri: lb://ruoyi-file
predicates:
- Path=/file/**
filters:
- StripPrefix=1
# 安全配置
security:
# 验证码
captcha:
enabled: true
type: math
# 防止XSS攻击
xss:
enabled: true
excludeUrls:
- /system/notice
# 不校验白名单
ignore:
whites:
- /auth/logout
- /auth/login
- /auth/register
- /*/v2/api-docs
- /csrf
Spring Cloud Gateway配置
routes
:定义 Spring Cloud Gateway的路由规则id: ruoyi-auth
: 路由规则的ID,用于标识该路由规则uri: lb://ruoyi-auth
:路由的目标地址,使用负载均衡的方式访问ruoyi-auth
服务- lb : load balance 负载均衡
predicates
: 定义路由规则的匹配条件- Path=/auth/**
:请求路径匹配规则,表示所有以/auth/
开头的请求
filters
:定义过滤器,对请求进行过滤和处理- CacheRequestFilter
:缓存请求的过滤器- ValidateCodeFilter
: 验证码验证过滤器- StringPrefix=1
:移除请求路径中的/auth
前缀
2.3、过滤器工厂
GatewayFilter是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理。
在过滤器这里可参考:SpringCloud过滤器工厂
2.3.1、全局过滤器
2.4、断言工厂
- 这里我之前的笔记也有记录:SpringCloud断言工厂
3、业务逻辑
3.1、被定位至登录
当我们请求localhost:80
也就是相当于请求path为空,会被重定向到 index 首页,但是实际上是进入的登录login页面,为什么会这样呢?
- 实际上是前端的前置路由会去判断此次请求是否携带了 token,如果没带且不在白名单内,则会重定向至登录页面
3.2、记住密码逻辑
记住密码若依是什么逻辑呢?在login.vue
里面可以看到,进入页面会执行getCookie
方法,也就是假如点击了记住密码,那么首先会从Cookie里面去找username和password。
而且我们的账号密码默认填充的,也是在data数据下就可以找到。
3.3、dev-api
打开F12我们会发现前端在调用API的时候,会带一个/dev-api
的url,这是为什么呢?是因为若依在封装axios的时候,会配置一个baseURL的字段,表示请求URL的公共部分。
前端使用的是80端口,后端使用的是8080端口,开发环境会在本地对请求的 URL 进行拦截,拦截如下:
http://localhost/dev-api/**
会被拦截并处理成http://localhost:8080/**
- 8080也就是后端网关模块的端口
javascript
// webpack-dev-server 相关配置
devServer: {
// 让你的服务器可以被外部访问
host: '0.0.0.0',
// 指定监听请求的端口号
port: port,
// 告诉 dev-server 在服务器已经启动后打开浏览器。设置其为 true 以打开你的默认浏览器
open: true,
// 启用代理
proxy: {
// 当是process.env.VUE_APP_BASE_API也就是 '/dev-api' 的url时,将代理转到 http://localhost:8080
// 即对于'/dev-api/**'的请求会将请求代理到 http://localhost:8080/dev-api/**
[process.env.VUE_APP_BASE_API]: {
target: `http://localhost:8080`,
// 默认情况下,代理时会保留主机头的来源,可以将 changeOrigin 设置为 true 以覆盖此行为
changeOrigin: true,
// 重写路径,将 process.env.VUE_APP_BASE_API也就是 '/dev-api' 换为空
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: ''
}
}
},
disableHostCheck: true
},
[!note]
关于devServer的配置可以参考官方文档:devServer
3.4、验证码
在之前我们看到 redis 微服务比分离版本会多存一个captcha_codes
的键值对
其实它是存了一个前缀:uuid的captcha_codes:uuid
的键,值为验证码的答案,所以KV就是键值对。在点击登录的时候,前后会将这个uuid传给后端,后端就可以根据前缀:uuid
从redis中找到所对应的答案,与我们输入的验证码答案进行比对。
3.5、登录
整体而言,前端收集表单,四个内容:uuid、验证码答案、用户名、密码,将其传入后端进行认证操作。
uuid 其实是在进入登录页面的时候,会向redis中存一个
captcha_codes:uuid
的K,验证码答案为V。
后端查询数据库,查询到用户之后,把用户信息放入 Redis 中,同时生成一个 token 给前端,之后前端需要请求后端API的时候,每次都需要拿着 token,键是login_tokens: + 当前的tokenId
,tokenId是一个uuid,值是用户的各种信息,这样下次用户带着token来的时候,后端可以根据 tokenId 来 redis 中匹配。(取自若依分离版笔记)
3.5.1、登录前端
在登录的时候会调用handleLogin()
方法,这里复习一下,在标签中加了ref="loginForm"
,就可以在方法中通过this.$refs.loginForm
来获取DOM,这里也就是获取到 <el-form></el-form>
。
通过 validate 进行验证,如果this.loginForm.rememberMe
为true,也就是勾选了记住我,那么就先从 Cookie 中获取用户名和密码。
如果未勾选记住我,则会通过this.$store.dispatch
触发 action 的方法,并且传入 this.loginForm
对象参数,之后导航到this.redirect
指定的路径,如果没有指定则导航到根路径"/"
。
actions 的方法如下:
javascript
actions: {
// 登录
Login({ commit }, userInfo) {
// 从userInfo对象中提取username, password, 验证码答案code和uuid字段
const username = userInfo.username.trim()
const password = userInfo.password
const code = userInfo.code
const uuid = userInfo.uuid
//返回一个新的Promise,以便处理异步操作
return new Promise((resolve, reject) => {
login(username, password, code, uuid).then(res => {
let data = res.data
//设置全局的token
setToken(data.access_token)
//在Vuex store中提交一个action来更新token
commit('SET_TOKEN', data.access_token)
//设置过期时间
setExpiresIn(data.expires_in)
//更新Vuex store中的过期时间
commit('SET_EXPIRES_IN', data.expires_in)
// 解析Promise,表示登录成功
resolve()
}).catch(error => {
//捕获login函数中可能抛出的错误,并拒绝Promise
reject(error)
})
})
},
}
Login
函数接受两个参数,第一个是一个包含commit
方法的对象,这是Vuex提供的用于改变store状态的方法。第二个参数userInfo
是一个对象,包含了登录所需的信息,如用户名、密码、验证码答案code和UUID- 使用
new Promise
创建了一个新的Promise对象,这样就可以在异步操作完成后通过resolve
或reject
来处理结果。 login
函数是一个异步操作,它接收登录信息并返回一个Promise。当这个Promise解析时,会处理服务器响应,提取数据并执行相应的操作。- 使用
commit
方法提交两个mutation------SET_TOKEN
和SET_EXPIRES_IN
,这会更新Vuex store中的状态
3.5.2、表单校验
这里再复习一下表单校验
- 在 form 表单加
:rules="loginRules"
- 在el-form-item里面添加prop,prop对应 v-model="loginForm" 的 loginForm 的属性
- 在 data 里面添加属性
loginRules
- 在点击提交的按钮里面
validate
校验,也就是上方的this.$refs.loginForm.validate
3.5.3、登录后端
总结:在点击登录按钮的时候,请求路径为http://localhost/dev-api/auth/login
,请求方式是 POST,前端会将请求代理转到http://localhost:8080
,同时会把 dev-api 前缀给干掉,最后请求后端实际的 url 是 http://localhost:8080/auth/login
这样的请求进入网关,路径为 /auth/**
开头的请求被注册中心 nacos 负载均衡打入微服务 ruoyi-auth 中,并且经过三个局部过滤器,如下图:
- CacheRequestFilter:获取 body 请求数据(解决流不能重复读取问题)
- ValidateCodeFilter:验证码校验过滤器,用于校验验证码输入对不对
- StripPrefix:去掉最前面的一级路径前缀
这里我们来Debug,Debug之前先去共享配置application-dev.yml
里面改个超时时间:
分别以Debug的方式启动 Auth、Gateway、System微服务,并且在过滤器上分别打上断点,走完Gateway模块后,会去远程调用Auth鉴权模块的/login
1、验证码过滤器
我们点击登录,会去调用/auth/login
接口,
在后端看缓存请求过滤器CacheRequestFilter,这个过滤器主要是获取body请求数据,并且解决流不能重复读取的问题:
从这里是可以看到过滤器链条,可以发现order
的值越小,那么它优先级越高,通过缓存请求过滤器CacheRequestFilter获取body数据,之后再进入ValidateCodeFilter验证码过滤器,并且我们的全局过滤器AuthFilter、XssFilter的的order是 -200、-100。
之后我们进入ValidateCodeFilter
验证码过滤器,index显示的是10,也就是走的是第10个过滤器,下标为9。
ValidateCodeFilter
验证码过滤器的逻辑是这样的,首先拿到我们请求的URL,看看请求是不是/auth/login 或者 /auth/register,如果不是则不作处理,退出过滤器。
如果是注册或者登录,那么就要进行验证码过滤器,首先获取到请求体,也就是我们在前端传的四个参数,在这里被获取到了。
JSON.parseObject(rspStr)
也就是将String转为一个HashMap,键值对。
最后本地调用validateCodeService
来checkCaptcha校验验证码,也就是获取到验证码答案和uuid,之后肯定是去redis里面查询,根据captcha_codes:uuid
为键来查询值,如果和答案一致则通过。
2、登录逻辑
上面的过滤器走完后就会去调用其他微服务了,调用的是鉴权模块auth的/login
登录接口。
sysLoginService.login
方法如下:
- 方法里面对用户名和密码有最大长度和最小长度限制,在
UserConstants.java
类里面 - 同时在redis中存有IP黑名单
上面的判断条件走完之后,就真正开始调用远程服务查询用户信息了,remoteUserService.getUserInfo
,而remoteUserService
是ruoyi-api模块下的,这里复习一下,凭什么 ruoyi-auth 模块可以调用 ruoyi-api 下的方法?
- 是因为 auth 模块引入了 ruoyi-api 模块依赖
我们可以发现remoteUserService
上方加了注解@Autowired
,说明这个接口是在Spring容器里面,它凭什么在Spring容器里面呢?是因为加了@FeignClient
注解
这个注解为什么可以生效呢?是因为 ruoyi-auth 的启动类加了@EnableRyFeignClients
注解,这个注解是由ry重写的,真正有用的其实是注解内部的@EnableFeignClients
注解:
若依重写这个注解的目的是为了扫描包,com.ruoyi
下面的所有包,如果不这么写,扫描的包就是当前包和子包,比如当前包是com.ruoyi.auth
,那么在 ruoyi-api 模块可能是没有com.ruoyi.auth
这个包的,就会扫描不到了。
现在真正开始远程调用了remoteUserService.getUserInfo(username, SecurityConstants.INNER)
,这个SecurityConstants.INNER
在若依中就表示inner
,也就是内部模块调用。
而 getUserInfo 方法会传递一个 username,同时会在请求头里面放一个 source,表示源,其实也就是我们的inner
,表示这是内部模块的调用。
在ruoyi-system模块下的/user/info/{username}
方法上面有注解@InnerAuth
表示这是微服务内部调用,并且这个方法是切面的:
这个切面首先会获取请求头,看是否是内部请求,如果你请求头里面有inner
,才会让你调用这个接口。
接着就正式执行info
方法了,首先查询sqluserService.selectUserByUserName(username)
得到用户信息,如下图。
接着根据用户信息查询其角色,permissionService.getRolePermission(sysUser)
,在查询角色时,只要userId=1且不为空,就会加上admin
的角色。
接着查询权限,permissionService.getMenuPermission(sysUser)
,查询到超级管理员就是全部权限*.*.*
,之后新创建一个用户,将我们查询到的用户、角色、权限都赋值给这个用户。
总之最后经过一系列就拿到了当前用户的所有信息:
但是这个信息非常多,data.sysUser
里面是我们着重关注的,这里我们拿到后判断用户是否有被删除,是否有被停用。
最后一步就是判断用户的密码是否正确,校验密码如下,首先在 redis 中会存重试的次数,当输错5次后,账户会被锁定10分钟。
若依自己写了密码校验,也就是 matches(user,password) 方法,直接调用SecurityUtils
工具类进行校验。
最后登录成功,记录登录日志:
3、生成token
这下我们将用户的信息全部拿到,就可以生成Token了,生成后的Token使用R.ok
返回给前端。
我们进去看一下是如何createToken
的:
- 获取uuid
- 获取userid
- 获取username
- 设置token(其实是设置的uuid)
- 设置userid
- 设置username
- 刷新token
进去看一下刷新token是干嘛的:
- 设置当前时间为登录时间
- 设置过期时间
- 设置存入redis的key键为
login_tokens:uuid
- 设置真实的token,键为
login_tokens:uuid
,值为loginUser
对象
走完这一步在 redis 中就可以看到存入的KV啦!
4、JWT存储信息
创建一个HashMap,存入 user_id、user_key(也就是uuid)、username,利用这三个信息,生成一个Token。之后Token解析完之后就可以拿到uuid,就能去redis中取出用户的全部信息了。
又新建一个HashMap,将access_token:token
、expires_in:720
存入返回给前端。
我们来看看真正的token长什么样子:是一长串字符串,根据这一长串字符串就可以解析出user_id、user_key(也就是uuid)、username。
3.5.4、登录前端
前端请求数据后拿到 response,通过 response.data 可以拿到后端传回来的数据,也就是上方返回的 rspMap。
前端做的事情很简单,一句话概括:将token和过期时间通通放入Cookie和Vuex中。
登录完成后,路由帮我们进入到我们之前想进入的。打个比方:我们直接在浏览器输入localhost:/system/user
,未登录的状态下肯定是进入了登录页面,登录成功后会帮我们再路由到/system/user
页面。
但是在变路由的状态下,都会触发全局路由守卫:
但是我们假如是要去/index
,就会else的代码,首先判断是否有角色,肯定没有角色了,我们只拿到了token和过期时间,怎么会有角色呢?所以这个时候前端还需要再调用 GetInfo 获取用户信息接口、GenerateRoutes 获取路由接口 ,在GenerateRoutes
接口里面会设置一系列路由,由此就会渲染出相应的菜单。
3.5.5、获取用户信息前端
在getToken方法中,其实是去Cookie中确认取Admin-Token:token
的键值对
是因为在我们登录成功后,浏览器的Cookie会存Admin-Token:token
的键值对:
我们接着来看代码getToken() && !isToken
,在获取用户详细信息的接口中,我们没有指明headers.isToken
的值,则 config.headers
就会为空,(config.headers || {}).isToken
的值就会变成undefined,则 isToken就会是 false,则getToken() && !isToken
就会是 true。
那么就会走config.headers['Authorization'] = 'Bearer ' + getToken()
这句代码,也就是给请求加一个头:Authorization:Bearer + token
,这样就达成了每次 request 发请求的时候,会默认带上这个token。
当我们需要接口不需要携带token的时候,只需要加上headers: {isToken: false}
在获取用户信息,也就是调用GetInfo
的actions,其中会调用getInfo
方法,此方法需要携带Token进行请求。
3.5.6、获取用户信息后端
getInfo请求在发送到后端时会先走全局过滤器AuthFilter
和XssFilter
:我们是可以Debug到getInfo获取用户信息的接口确实携带了token,在请求头里面。
这个过滤器大概讲解一下思路:
- 首先获取到请求的 request,然后使用建造者模式拷贝一份,目的是为了不污染原始请求的 request,把请求的 Path 取出,将其检验,看是不是不需要验证的路径,比如注册、登录是不需要auth授权验证的,如果确实是不需要验证的路径,直接放行。
- getInfo是需要验证的,所以从请求的 request 中获取到 token,其实也就是从请求头中拿到
Authorization:Bearer token
,然后通过裁剪掉前缀 Bearer,返回token
- 判断获得token是否为空,然后从token中解出user_id、user_key(也就是uuid)、username,因为我们之前也是用这三个信息生成的token。
- 拿出 user_key(也就是uuid) ,拼接上
login_tokens:
,在 redis 中查询是否有login_tokens: uuid
对应的值,这个值就是我们之前说的,当前用户的所有信息。
所以相当于获取用户信息getInfo请求每次都会解析token,从redis 中查用户信息,走这么个作用的过滤器。
- 解析token成功之后,获取userid、username。我们把拷贝的请求构造器拿出来,设置请求构造器的请求头,设置为
userkey:uuid
、userid: 1
、username: "admin"
,相当于存储了整个用户,毕竟可以从 uuid 中到 redis 中获取全部信息。
安全上下文在这步之后会从请求头中拿到这些信息设置到安全上下文 SecurityContextHolder,这个安全上下文是若依自己写的,我们可以理解为就是一个 ThrealLocal,线程私有,在这个 ThrealLocal 里面放点东西,在后面可以一直取。之后我们再来讲解这个 SecurityContextHolder
-
在请求构造器的头里面删除了
from-source
字段,因为内部调用这个from-source
字段是 inner,删除后防止伪造内部调用。 -
完成后走其他的过滤器,这里不作分析
[!note]
至于不需要auth授权验证的路径其实也是在nacos中配置的,是因为这个
ignoreWhite.getWhites()
上方有注解@ConfigurationProperties(prefix = "security.ignore")
,读取的是 yml 里面的路径。
- 发生服务间的调用,通过安全上下文 SecurityUtils 获得用户ID,进而查询出当前用户的全部信息
- 根据当前用户信息查询出用户的角色、用户的权限,将用户角色和权限包装,连同用户一起返回给前端。
这样getInfo接口就调用完毕了,最终返回给前端的数据如下:
json
{
"msg": "操作成功",
"code": 200,
"permissions": [
"*:*:*"
],
"roles": [
"admin"
],
"user": {
"createBy": "admin",
"createTime": "2024-07-10 00:37:05",
"updateBy": null,
"updateTime": null,
"remark": "管理员",
"userId": 1,
"deptId": 103,
"userName": "admin",
"nickName": "若依",
"email": "ry@163.com",
"phonenumber": "15888888888",
"sex": "1",
"avatar": "",
"password": "$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2",
"status": "0",
"delFlag": "0",
"loginIp": "127.0.0.1",
"loginDate": "2024-07-20T15:01:58.000+08:00",
"dept": {
"createBy": null,
"createTime": null,
"updateBy": null,
"updateTime": null,
"remark": null,
"deptId": 103,
"parentId": 101,
"ancestors": "0,100,101",
"deptName": "研发部门",
"orderNum": 1,
"leader": "若依",
"phone": null,
"email": null,
"status": "0",
"delFlag": null,
"parentName": null,
"children": []
},
"roles": [
{
"createBy": null,
"createTime": null,
"updateBy": null,
"updateTime": null,
"remark": null,
"roleId": 1,
"roleName": "超级管理员",
"roleKey": "admin",
"roleSort": 1,
"dataScope": "1",
"menuCheckStrictly": false,
"deptCheckStrictly": false,
"status": "0",
"delFlag": null,
"flag": false,
"menuIds": null,
"deptIds": null,
"permissions": null,
"admin": true
}
],
"roleIds": null,
"postIds": null,
"roleId": null,
"admin": true
}
}
[!NOTE]
好玩的JSON工具推荐:JsonCrack
3.5.7、获取路由信息后端
我们知道在登录的时候不光调用了getInfo获取用户信息接口,还调用了 getRouters 方法
getRouters 方法也会走全局过滤器 AuthFilter,之后在发生微服务调用,我们这里来说一下安全上下文SecurityContextHolder
,其实它是在拦截器 HeaderInterceptor 里面执行的,通俗来说:
- getInfo接口调用 - 全局过滤器 - 局部过滤器 - HeaderInterceptor 拦截器
- getRouters接口调用 - 全局过滤器 - 局部过滤器 - HeaderInterceptor 拦截器
拦截器里面将user_id、user_key(也就是uuid)、username设置进安全上下文中,同时将当前登录的用户对象也放进安全上下文中。
SecurityContextHolder 安全上下文里面存的就是当前线程变量中的 用户id、用户名称、Token等信息,我们可以通过权限获取工具类:
SecurityUtils.getToken()
直接获取到token!SecurityUtils.getUserId()
直接获取到userid!SecurityUtils.getUsername()
直接获取到username!SecurityUtils.getLoginUser()
直接获取到 loginUser!这里多说一点,
SecurityContextHolder
安全上下文可以通过SecurityUtils
权限获取工具类获取当前线程变量中的 用户id、用户名称、Token等信息,如果你要获取多余的信息,需要两步:
- 在
AuthFilter
中通过请求头的方法传入- 在
HeaderInterceptor
中设置
这样我们拦截器算是简单看完了,可以接着看 getRouters 方法了
- 首先获取 userid
- 根据 userid 查询菜单树
- 进入
menuService.selectMenuTreeByUserId(userId)
方法查看,首先创建一个 List,询问你是否是管理员admin,如果是管理员,则menuMapper.selectMenuTreeAll()
给全部的菜单。如果不是管理员,则menuMapper.selectMenuTreeByUserId(userId)
最后根据getChildPerms
方法将查询出的目录、菜单生成一个树结构返回给前端
这样最终 getRouters 返回给前端的数据如下:
json
{
"msg": "操作成功",
"code": 200,
"data": [
{
"name": "System",
"path": "/system",
"hidden": false,
"redirect": "noRedirect",
"component": "Layout",
"alwaysShow": true,
"meta": {
"title": "系统管理",
"icon": "system",
"noCache": false,
"link": null
},
"children": [
{
"name": "User",
"path": "user",
"hidden": false,
"component": "system/user/index",
"meta": {
"title": "用户管理",
"icon": "user",
"noCache": false,
"link": null
}
},
{
"name": "Role",
"path": "role",
"hidden": false,
"component": "system/role/index",
"meta": {
"title": "角色管理",
"icon": "peoples",
"noCache": false,
"link": null
}
},
{
"name": "Menu",
"path": "menu",
"hidden": false,
"component": "system/menu/index",
"meta": {
"title": "菜单管理",
"icon": "tree-table",
"noCache": false,
"link": null
}
},
{
"name": "Dept",
"path": "dept",
"hidden": false,
"component": "system/dept/index",
"meta": {
"title": "部门管理",
"icon": "tree",
"noCache": false,
"link": null
}
},
{
"name": "Post",
"path": "post",
"hidden": false,
"component": "system/post/index",
"meta": {
"title": "岗位管理",
"icon": "post",
"noCache": false,
"link": null
}
},
{
"name": "Dict",
"path": "dict",
"hidden": false,
"component": "system/dict/index",
"meta": {
"title": "字典管理",
"icon": "dict",
"noCache": false,
"link": null
}
},
{
"name": "Config",
"path": "config",
"hidden": false,
"component": "system/config/index",
"meta": {
"title": "参数设置",
"icon": "edit",
"noCache": false,
"link": null
}
},
{
"name": "Notice",
"path": "notice",
"hidden": false,
"component": "system/notice/index",
"meta": {
"title": "通知公告",
"icon": "message",
"noCache": false,
"link": null
}
},
{
"name": "Log",
"path": "log",
"hidden": false,
"redirect": "noRedirect",
"component": "ParentView",
"alwaysShow": true,
"meta": {
"title": "日志管理",
"icon": "log",
"noCache": false,
"link": null
},
"children": [
{
"name": "Operlog",
"path": "operlog",
"hidden": false,
"component": "system/operlog/index",
"meta": {
"title": "操作日志",
"icon": "form",
"noCache": false,
"link": null
}
},
{
"name": "Logininfor",
"path": "logininfor",
"hidden": false,
"component": "system/logininfor/index",
"meta": {
"title": "登录日志",
"icon": "logininfor",
"noCache": false,
"link": null
}
}
]
}
]
},
{
"name": "Monitor",
"path": "/monitor",
"hidden": false,
"redirect": "noRedirect",
"component": "Layout",
"alwaysShow": true,
"meta": {
"title": "系统监控",
"icon": "monitor",
"noCache": false,
"link": null
},
"children": [
{
"name": "Online",
"path": "online",
"hidden": false,
"component": "monitor/online/index",
"meta": {
"title": "在线用户",
"icon": "online",
"noCache": false,
"link": null
}
},
{
"name": "Job",
"path": "job",
"hidden": false,
"component": "monitor/job/index",
"meta": {
"title": "定时任务",
"icon": "job",
"noCache": false,
"link": null
}
},
{
"name": "Http://localhost:8718",
"path": "http://localhost:8718",
"hidden": false,
"component": "Layout",
"meta": {
"title": "Sentinel控制台",
"icon": "sentinel",
"noCache": false,
"link": "http://localhost:8718"
}
},
{
"name": "Http://localhost:8848/nacos",
"path": "http://localhost:8848/nacos",
"hidden": false,
"component": "Layout",
"meta": {
"title": "Nacos控制台",
"icon": "nacos",
"noCache": false,
"link": "http://localhost:8848/nacos"
}
},
{
"name": "Http://localhost:9100/login",
"path": "http://localhost:9100/login",
"hidden": false,
"component": "Layout",
"meta": {
"title": "Admin控制台",
"icon": "server",
"noCache": false,
"link": "http://localhost:9100/login"
}
}
]
},
{
"name": "Tool",
"path": "/tool",
"hidden": false,
"redirect": "noRedirect",
"component": "Layout",
"alwaysShow": true,
"meta": {
"title": "系统工具",
"icon": "tool",
"noCache": false,
"link": null
},
"children": [
{
"name": "Build",
"path": "build",
"hidden": false,
"component": "tool/build/index",
"meta": {
"title": "表单构建",
"icon": "build",
"noCache": false,
"link": null
}
},
{
"name": "Gen",
"path": "gen",
"hidden": false,
"component": "tool/gen/index",
"meta": {
"title": "代码生成",
"icon": "code",
"noCache": false,
"link": null
}
},
{
"name": "Http://localhost:8080/swagger-ui/index.html",
"path": "http://localhost:8080/swagger-ui/index.html",
"hidden": false,
"component": "Layout",
"meta": {
"title": "系统接口",
"icon": "swagger",
"noCache": false,
"link": "http://localhost:8080/swagger-ui/index.html"
}
}
]
},
{
"name": "Http://ruoyi.vip",
"path": "http://ruoyi.vip",
"hidden": false,
"component": "Layout",
"meta": {
"title": "若依官网",
"icon": "guide",
"noCache": false,
"link": "http://ruoyi.vip"
}
}
]
}
3.5.8、获取路由信息前端
路由后端返回给前端,前端使用JSON.parse
解析了两份数据,相当于拷贝了一份。为什么要拷贝一份呢,是因为前端有两种菜单展示方式:侧边栏和顶栏。
动态地从服务器获取路由信息,处理这些信息,并将其添加到 Vue Router 和 Vuex store 中,以便在前端应用中正确地显示和导航。
3.5.9、获取用户信息前端补充
我们上方拿到了后端返回给前端getInfo接口的数据,getInfo接口前端是把数据放入了Vuex中:
- 拿到后端返回的user数据:
res.user
- 判断用户的头像avatar字段是否为空,如果为空,则默认设置
@/assets/images/profile.jpg
- 如果用户有角色,则提交角色、权限到Vuex中,如果没有角色,则给一个默认角色。
- 提交userid、username、avatar 到VueX中
至此,登录这块的前后端算是差不多写完了!业务逻辑是比较复杂一点!
3.6、权限
首先看一下sty_menu
菜单,其中menu_type
字段是菜单类型(M目录 C菜单 F按钮),perms
字段是权限标识,M目录无权限标识,C菜单和F按钮有权限标识,是xx:xx:xx
3.6.1、前端权限
我们之前说过,在用户登录进系统会调用getInfo
获得用户信息接口和getRouters
获取路由信息接口。其中getInfo
接口后端会返回给前端一系列权限字符串:我们使用admin
登录的权限permissions是*.*.*
,假如我们用ry
用户登录:
前端调用getInfo接口,会获得一系列权限字符串,那么前端权限的表现,就是v-hasPermi=['xxx']
:比如v-hasPermi="['system:user:add']"
,如果此用户有system:user:add
这个权限字符串,那么这个el-button
按钮就可以展示出来。
3.6.2、后端权限
后端的方法上有@RequiresPermissions("xxx:xxx:xxx")
的注解,有了这个注解,这个方法就会被AOP切入,判断当前用户是否具有xxx:xxx:xxx
这个权限,如果有这个权限,才可以被调用这个接口。
3.6.3、权限总结
-
定义权限
在Web端的菜单管理 去定义权限,主要是给菜单和按钮权限字符。会被添加到数据库中,在用户登录 getInfo 和 getRouters 的时候,会拿到这个权限字符。
- getInfo 查出权限,前端VueX中也存着用户的权限,根据权限展示目录和菜单
- getInfo 查询出的权限在后端的 Redis 中的 LoginUser 中存储一份,用于AOP切入,根据后端方法上的注解
@RequiresPermissions
完成权限的校验。