自身对于RuoYi-Vue的源码理解与分析
一、登录
1 验证码
1.1 实现思路
- 后端生成表达式:num1+num2=?@answer
- 将”num1+num2=?”转换成流并生成图片传入前端
- 将答案answer存入Redis
- 提交表单,验证提交的验证码是否与Redis中存放的答案一致
1.2 代码实现
1.2.1 前端代码
login.vue文件位置:/ruoyi-ui/src/views/login.vue
页面初始化:
1 | created() { |
生成验证码图片:
1 | methods: { |
login.js文件位置:/ruoyi-ui/src/api/login.js
获取验证码:
1 | export function getCodeImg() { |
request.js文件位置:/ruoyi-ui/src/utils/request.js
axios实例:
1 | // 创建axios实例 |
vue配置文件位置:/src目录下的三个配置文件:.env.development(开发环境)、.env.production(生产环境)、.env.staging(测试环境)
开发环境下的配置文件:
1 | # 页面标题 |
所以vue获取验证码图片的完整请求路径为http://localhost/dev-api/captchaImage
这边需要注意的是,为了解决跨域问题,URL请求的是前端,然后进行反向代理,映射到后端
vue.config.js文件位置:/ruoyi-ui/src/vue.config.js
1 | proxy: { |
1.2.2 后端代码
CaptchaController.java文件位置:
/ruoyi-admin/src/main/java/com.ruoyi/web/controller/common/CaptchaController.java
创建ajax对象,判断是否开启验证码功能(默认为开启)
1 | AjaxResult ajax = AjaxResult.success(); |
跟进AjaxResult的success()方法,发现其返回值由状态码、消息和数据组成
1 | /** |
创建完ajax对象后,生成用于存放在Redis中作为key的uuid和verifyKey字符串
1 | // 保存验证码信息 |
接着是验证码类型,分为数组计算(math)和字符验证(char)两种
1 | // 生成验证码 |
具体的验证码类型可以在application.yml中设置
这边对默认的math验证码,即数组计算验证码进行分析
1 | if ("math".equals(captchaType)) |
从代码中可以看出,生成验证码的本质是生成一个“num1+num2=?@answer”的表达式,然后对表达式进行分割处理
断点调试:
可以看到uuid、verifyKey、captchaType和capText已经生成了
再次断点调试:
可以看到表达式已经被分割成了两部分
分割完表达式后,将刚刚生成的verifyKey作为键,答案code作为值,存入Redis中,其中有效时间为2分钟
1 | redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES); |
在Redis中可以看到刚刚生成的verifyKey字符串、答案code以及生存时间TTL
存入Redis之后,将表达式的“问题”部分生成图片,然后将uuid和图片一起存入ajax对象中并返回
1 | // 转换流信息写出 |
2 用户登录
2.1 实现思路
- 用户通过表单将数据提交给前端
- 前端将得到的数据进行封装并传给后端
- 后端校验验证码
- 后端校验用户名和密码
- 生成Token
2.2 代码实现
2.2.1 前端代码
打开login.vue,可以发现登录页面是通过表单提交的形式完成的,并且可以发现登录方法是由handleLogin()方法实现的
1 | <el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form"> |
跟进handleLogin()方法,发现了”记住密码”功能的实现逻辑:如果勾选了,则将用户名和密码存入cookie,反之则将其从cookie中删除
1 | handleLogin() { |
继续跟进Login,发现这是一个Vuex封装的全局方法,保存了用户名、密码、验证码以及uuid
1 | // 登录 |
Login中还封装了一个Promise对象,用来实现异步处理,将后端验证完成后的Token保存在前端的cookie中
1 | return new Promise((resolve, reject) => { |
继续跟进Promise对象中的login方法,发现这是前端的登录方法,把封装的数据通过POST请求传递给后端
1 | // 登录方法 |
2.2.2 后端代码
SysLoginController.java文件位置:
/ruoyi-admin/src/main/java/com.ruoyi/web/controller/system/SysLoginController.java
1 |
|
断点调试:
可以看到前端传过来的json格式的数据
跟进loginService中的login()方法,在这个方法中实现了验证码校验、用户名密码校验以及Token生成
1 | public String login(String username, String password, String code, String uuid) |
跟进validateCaptcha()方法,这里将前端传来的数据和Redis中的数据(verifyKey和code)进行对比,若前端的验证码为空或者和Redis中的验证码不相同,则异步写入日志中,并抛出异常
1 | public void validateCaptcha(String username, String code, String uuid) |
验证码校验通过的话,就来到了用户校验这个模块,前后端分离版本的安全框架用的是Spring Security框架(Spring Boot版本用的是Shiro),同样的,用户校验也是通过前端传来的数据和MySQL中的数据进行对比,如果数据不匹配地话则异步写入错误日志并抛出异常,数据匹配的话则写入成功日志并继续往下走
1 | // 用户验证 |
跟进recordLoginInfo()方法,发现这是一个记录登录信息的方法,和前面的写日志不同,这里仅记录登录成功的用户信息,并且数据保存在MySQL数据库中的”sys_user”表中;而写日志则记录了用户的每一次操作,并且数据保存在MySQL数据库中的”sys_logininfor”表中
1 | /** |
层层跟进updateUserProfile()方法,最后在/ruoyi-system/src/main/java/resources/mapper.system/SysUserMapper.xml中找到更新用户信息的动态SQL语句
1 | <update id="updateUser" parameterType="SysUser"> |
断点调试:
重新登录后可以在控制台中打印出对应的SQL语句
其中insert语句来自于AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message(“user.login.success”)));**(异步处理)
update语句来自于recordLoginInfo()方法
最后是创建Token,跟进createToken()方法,可以看到给用户设置了一个uuid,然后调用setUserAgent()获取用户信息,调用refreshToken()设置登录有效时间
1 | public String createToken(LoginUser loginUser) |
setUserAgent()方法:
1 | public void setUserAgent(LoginUser loginUser) |
refreshToken()方法:
1 | public void refreshToken(LoginUser loginUser) |
跟进createToken(),发现用的是JWT来加密数据,生成Token
1 | private String createToken(Map<String, Object> claims) |
3 获取用户角色和权限
3.1 实现思路
- 设置全局路由,使得每个页面跳转的时候都要获取用户角色和权限
- 前端发送获取用户角色和权限的请求
- 后端收到请求后获取用户的角色和权限
3.2 代码实现
3.2.1 前端代码
在src目录下找到全局路由配置文件permission.js,在里面可以找到获取用户信息的方法
1 | if (store.getters.roles.length === 0) { |
跟进GetInfo方法,可以看到里面封装了一个Promise对象,通过commit()方法将读取到的用户角色和权限存入Vuex中
1 | // 获取用户信息 |
继续跟进getInfo()方法,得知这是前端用来获取用户信息的方法,通过GET方式发送请求给后端
1 | // 获取用户详细信息 |
3.2.2 后端代码
SysLoginController.java文件位置:
/ruoyi-admin/src/main/java/com.ruoyi/web/controller/system/SysLoginController.java
这段代码的逻辑很简单,就是获取用户的角色和权限,然后封装成ajax对象返回给前端
1 | /** |
断点调试:
可以看到此时登录的用户角色为”admin”、权限为”*:*:*“
需要注意的是,”*:*:*“是Spring Security中的表示方式,代表该用户拥有所有权限,其中星号表示通配符
跟进getRolePermission()方法,代码逻辑也很简单,判断该用户是否为管理员,是的话则添加一个”admin”标记,不是的话返回数据库查找对应的角色
1 | /** |
继续跟进isAdmin()方法,这里是判断该用户在数据库中的角色编号是否为管理员编号
1 | public boolean isAdmin() |
获取用户权限和获取用户角色的代码逻辑大同小异,这里不再展开叙述
1 | /** |
1 | /** |
3.3 数据库设计
和这部分相关的数据库表一共有三张,分别是sys_user、sys_role和sys_user_role表
3.3.1 sys_user表
这张表存放了用户的一些基本信息,在这里最重要的是user_id字段,它是该表的主键,和用户是一对一的关系
3.3.2 sys_role表
这张表存放了不同角色的信息,在这里最重要的是role_id字段,它是该表的主键,和角色是一对一的关系
3.3.3 sys_user_role表
这张表是sys_user表和sys_role表的中间表,它记录了不同用户所对应的不同角色
4 获取动态菜单路由
4.1 实现思路
- 根据上一步获取的用户权限,发送不同的请求给后端
- 后端根据前端发来的请求,从数据库中获取不同的菜单信息并封装成动态路由,返回给前端
4.2 代码实现
4.2.1 前端代码
在/src/permission.js中跟进GenerateRoutes,发现这是前端向后端请求路由数据的方法
1 | actions: { |
继续跟进getRouters()方法,可以知道该方法是通过GET方式发送请求给后端的
1 | // 获取路由 |
4.2.2 后端代码
SysLoginController.java文件位置:
/ruoyi-admin/src/main/java/com.ruoyi/web/controller/system/SysLoginController.java
在SysLoginController.java文件中找到getRouters()方法,代码的逻辑很简单,就是根据当前用户的id去查找菜单来生成动态路由
1 | /** |
跟进selectMenuTreeByUserId()方法,可以看到获取路由的逻辑和获取用户权限大同小异
1 |
|
继续跟进selectMenuTreeAll(),找到system/src/main/java/resources/mapper.system/SysUserMapper.xml,可以看到存储菜单信息的数据库是sys_menu
1 | <select id="selectMenuTreeAll" resultMap="SysMenuResult"> |
sys_menu表信息:
表中的每一条信息都有两个很重要的字段:menu_id和parent_id,这里面的设计逻辑是,parent_id为0的即为主菜单,而parent_id为其他数字的则为该数字对应的menu_id的菜单的子菜单
举个例子:
系统管理、系统监控、系统工具的parent_id为0,所以它们就是系统的主菜单
而用户管理、角色管理的parent_id为1,则它们是系统管理的子菜单
同理,在线用户的parent_id为2,则它是系统监控的子菜单
后端从数据库中读取菜单信息后,使用getChildPerms()方法对读取到的数据进行数据封装
在getChildPerms()方法中:
在之前的selectMenuTreeByUserId()方法中传递了两个参数:从数据库中读取的数据和0,其中0表示父节点的ID,即从主菜单开始获取子菜单信息,然后根据主菜单个数进行循环调用recursionFn()方法,获取子节点的信息
1 | /** |
在recursionFn()方法中:
这边最主要的是递归调用,根据自己的节点ID,寻找自己的子菜单
其中递归出口为hasChild()方法
1 | /** |
在hasChild()方法中:
使用三元表达式,根据列表长度判断是否存在子节点
1 | /** |
二、前台数据加载
1 首页
从URL中我们可以发现,首页的加载文件是index.vue,所以在/src/views/index.vue即可看到首页的前端代码
1 | h2>若依后台管理框架</h2> |
2 侧边菜单、主信息窗口
在前面我们已经知道,侧边菜单的来源是动态路由信息,所以我们可以在路由文件,也就是/src/router/index.js中找到根目录的前端代码
1 | { |
从代码中我们可以发现,这里使用的是Layout组件,所以我们打开/src/layou/index.vue文件,从前端JS代码中可以发现使用了siderbar来实现侧边菜单功能、使用app-main实现主信息窗口功能
1 | <template> |
查看siderbar,也就是/src/layout/components/Siderbar/index.vue,从中可以看到实现侧边的关键代码:
1 | <sidebar-item |
3 管理菜单页面
点击任一管理菜单(以用户管理为例),我们会发现URL的后缀为/system/user,这是因为在数据库中,每个菜单都绑定了对应的资源
3.1 获取用户列表
3.1.1 前端代码
查看/src/views/system/user/index.vue,我们可以看到该页面的前端代码
1 | <el-col :span="12"> |
在前端代码中找到created()方法,可以看到页面的数据来源是getList()和getTreeselect()
1 | created() { |
跟进getList()方法,可以看出这是一个获取用户列表的方法,还可以发现代码入口:listUser()
1 | /** 查询用户列表 */ |
跟进listUser(),可以知道该方法是通过GET方式发送请求给后端的
1 | // 查询用户列表 |
3.1.2 后端代码
SysUserController.java文件位置:
/ruoyi-admin/src/main/java/com.ruoyi/web/controller/system/SysUserController.java
在SysUserController.java中找到list()方法,代码的逻辑是先判断当前用户权限,拥有权限才能够进入list()方法
1 | /** |
跟进startPage(),可以看出这是个设置分页的方法
先创建一个PageDomain对象,用来从HTTP请求中获取分页相关参数(通过自定义工具类);接着从PageDomain对象中将数据取出,做一个非空判断,然后设置属性orderBy和reasonable,调用PageHelper.startPage()把参数传进去
1 | /** |
跟进selectUserList(),可以看出这是个查询数据的方法
代码逻辑很简单,调用Mapper去查询sys_user表中的数据即可
1 | /** |
跟进getDataTable(),可以看出这是个响应前后端分离的数据封装方法
1 | /** |
3.2 获取部门树
3.2.1 前端代码
跟进getTreeselect()方法,可以看出这是一个获取部门树的方法,还可以发现代码入口:treeselect()
1 | /** 查询部门下拉树结构 */ |
跟进treeselect(),可以知道该方法是通过GET方式发送请求给后端的
1 | // 查询部门下拉树结构 |
3.2.2 后端代码
SysDeptController.java文件位置:
/ruoyi-admin/src/main/java/com.ruoyi/web/controller/system/SysDeptController.java
在SysDeptController.java文件找到treeselect()方法,获取部门树的逻辑和获取用户列表差不多,区别在于这边没有设置分页,而且返回的数据对象类型也不同
1 | /** |
跟进selectDeptList(),可以看到也是调用Mapper从sys_dept表中获取数据
1 | /** |
跟进buildDeptTreeSelect()方法,可以看出这边通过调用buildDeptTreeSelect()方法来构建树,然后将构建好的树进行封装并返回
1 | /** |
跟进buildDeptTree(),代码逻辑和侧边菜单的实现差不多,都是通过递归来找到根节点和对应的子节点
1 | * 构建前端所需要树结构 |
三、用户操作
1 条件查询
1.1 实现思路
实现的思路很简单,就是在获取用户列表的基础上,加上一个条件判断:根据页面点击的部门不同,前端得到一个不同的id,然后再根据这个id去数据库中筛选不同的数据,最后返回到前端
1.2 代码实现
1.2.1 前端代码
查看/src/views/system/user/index.vue,找到el-tree模块的代码,可以发现绑定按钮点击的方法:handleNodeClick
1 | <el-tree |
跟进handleNodeClick,可以看出点击按钮后,前端会获取当前的部门id并返回给后端,然后调用handleQuery()
1 | // 节点单击事件 |
跟进handleQuery(),可以看到这边又调用了getList()方法,结合上面的handleNodeClick来看,整个流程就是在获取用户列表的基础上加上一个条件判断
1 | /** 搜索按钮操作 */ |