文章目录
- 0. 前言
-
- 目标
- 功能的基本流程
- 环境要求
- 1. 运行Ruoyi
-
- 1.1 下载
- 1.2 配置数据库
- 1.3 配置Redis
- 1.4 日志
- 1.5 启动后端
- 1.6 启动前端
- 2. 登陆功能
-
- 2.1 验证码
-
- 基本思路
- 前端实现
-
- 请求的封装
- 反向代理
- 后端实现
- 2.2 登陆
-
- 前端实现
- 后端实现
-
- 控制层
- 业务层
- 2.3 获取用户角色和权限
-
- 前端实现
- 后端实现
- 2.4 获取动态菜单路由
- 3. 数据加载
-
- 3.1 首页数据加载
- 3.2 用户管理(PageHelper分页)
- 3.3 部分树状图
- 4. 用户增删查改
- 5. 异步任务管理器
- 6. 代码自动生成
0. 前言
Ruoyi前后端分离版:SpringBoot + Vue
官网:https://ruoyi.vip
参考视频:【开源项目学习】若依前后端分离版,通俗易懂,快速上手
目标
学习开源项目的目标:
- 用,减少自己的工作量
- 学习优秀开源项目的底层编程思想、设计思路,提升自己的编程能力
使用、学习开源项目的流程:
- 下载并运行
- 看懂业务流程
- 进行二次开发
功能的基本流程
- 加载Vue页面
- 请求后端
环境要求
- JDK1.8+
- MySQL8+
- Redis
- Maven
- Vue
1. 运行Ruoyi
1.1 下载
从Gitee官网复制url在IDEA中打开(后端),注意前端Vue项目ruoyi-ui
需要额外使用一个idea打开。
1.2 配置数据库
表:直接执行/sql
下的两个sql文件,在本地创建表
数据源:修改配置文件中数据源配置
1.3 配置Redis
使用Docker启动Redis
修改Redis配置
1.4 日志
需要在ruoyi-admin/src/main/resources/logback.xml
中修改日志存放位置:
1.5 启动后端
启动admin中的springboot启动类
(♥◠‿◠)ノ゙ 若依启动成功 ლ(´ڡ`ლ)゙
.-------. ____ __
| _ _ \ \ \ / /
| ( ' ) | \ _. / '
|(_ o _) / _( )_ .'
| (_,_).' __ ___(_ o _)'
| |\ \ | || |(_,_)'
| | \ `' /| `-' /
| | \ / \ /
''-' `'-' `-..-'
1.6 启动前端
根据ruoyi-ui
项目中的README.md
文件进行配置安装依赖,然后启动
# 克隆项目
git clone https://gitee.com/y_project/RuoYi-Vue
# 进入项目目录
cd ruoyi-ui
# 安装依赖
npm install
# 建议不要直接使用 cnpm 安装依赖,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题
npm install --registry=https://registry.npmmirror.com
# 启动服务
npm run dev
2. 登陆功能
2.1 验证码
基本思路
简而言之:前端让后端出一道算术题,后端把题目告诉前端,并把答案放入后端的Redis中,前端计算完结果后去后端的Redis中比对答案。
每次需要登录时,会在后端自动生成验证码,如“1+1=?@2”,验证码“1+1=?”会被转成图片传到前端登陆页面,答案“2”会被存储进Redis中(@是用于分割问题和答案的标记符号)。当前端输入完账号密码和验证码后,系统会拿验证码“2”和Redis中的答案“2”进行比较,成功则再验证账号密码。Redis中的key值会被传到前端,如果有多人登陆,每个客户端可根据自己的key值查询redis中的value值(答案)。
如果通过Docker启动的Redis,可通过交互模式进入Redis容器,然后进入Redis客户端查看验证码答案。
# 通过交互模式进入Redis容器
docker exec -it 6ce bash
# 进入Redis
redis-cli
# 查看所有key
keys *
# 查看验证码答案
127.0.0.1:6379> get captcha_codes:aecd3ba23ab94614b2a7840e2625107c
"\"35\""
前端实现
请求的封装
验证码的代码实现在ruoyi-ui/src/views/login.vue
中。
基本流程概括:打开登陆页面,向后端请求验证码图片和一个uuid(Redis的key)
前端Vue和后端Springboot交互时通常使用axios
(ajax),而这里看不到axios的调用是因为进行了多次封装。如果再深入追溯,可进入getCodeImg()
方法,然后发现还有封装:
进入login.js
找到getCodeImg()
,发现了ajax的基本写法:url、请求类型、超时时间,注意至此还是在request
封装中,依旧没有看到axios。
// 获取验证码
export function getCodeImg() {
return request({
url: '/captchaImage',
headers: {
isToken: false
},
method: 'get',
timeout: 20000
})
}
接着进入ruoyi-ui/src/utils/request.js
,找到axios:
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
// axios中请求配置有baseURL选项,表示请求URL公共部分
baseURL: process.env.VUE_APP_BASE_API,
// 超时
timeout: 10000
})
其中VUE_APP_BASE_API
定义在了配置文件.env.development
中:
# 若依管理系统/开发环境
VUE_APP_BASE_API = '/dev-api'
这样任何请求都添加前缀’/dev-api’。
统一前缀是为了区分开发环境和生产环境。
反向代理
此时注意一个点:Vue获取图片时,前端项目的端口是1024;后端项目的端口是8080。理论上对验证码的信息的请求应该是对后端发起请求,但是url中实际还是对前端1024端口请求。
原因:反向代理,url在前端请求前端,进行代理,映射到后端,如此操作是为了解决跨域问题。跨域问题在后端的解决方式是Springboot添加一个配置类;前端的解决方式是反向代理。跨域问题在前端或者后端解决都可。
反向代理的配置在ruoyi-ui/vue.config.js
中:
// webpack-dev-server 相关配置
devServer: {
host: '0.0.0.0',
port: port,
open: true,
proxy: {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {
target: `http://localhost:8080`,
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: ''
}
}
},
disableHostCheck: true
},
上面的pathRewrite
里,会把前面的请求前缀替换为空,即’',再映射到后端的端口,即target
。如此请求url从http://localhost:1024/dev-api/captchaImage
变成了http://localhost:8080/captchaImage
。
后端实现
首先先定位到验证码功能的控制器,使用全局搜索(ctrl+shift+F)对admin项目搜索captchaImage
,找到CaptchaController
。
/**
* 生成验证码
*/
@GetMapping("/captchaImage")
public AjaxResult getCode(HttpServletResponse response) throws IOException
{
// 最终需要返回给前端的ajax结果(封装版)
AjaxResult ajax = AjaxResult.success();
// 检查是否开启验证码
boolean captchaOnOff = configService.selectCaptchaOnOff();
ajax.put("captchaOnOff", captchaOnOff);
if (!captchaOnOff)
{
return ajax;
}
// 保存验证码信息
String uuid = IdUtils.simpleUUID();
// 拼接一个key,用于放入redis,如“captcha_codes:aecd3ba23ab94614b2a7840e2625107c”
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
String capStr = null, code = null;
BufferedImage image = null;
// 生成验证码
String captchaType = RuoYiConfig.getCaptchaType();
if ("math".equals(captchaType))
{
String capText = captchaProducerMath.createText();
capStr = capText.substring(0, capText.lastIndexOf("@"));
code = capText.substring(capText.lastIndexOf("@") + 1);
image = captchaProducerMath.createImage(capStr);
}
else if ("char".equals(captchaType))
{
capStr = code = captchaProducer.createText();
image = captchaProducer.createImage(capStr);
}
// 将key和value存入redis,并设置缓存时间
redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
// 转换流信息写出
FastByteArrayOutputStream os = new FastByteArrayOutputStream();
try
{
ImageIO.write(image, "jpg", os);
}
catch (IOException e)
{
return AjaxResult.error(e.getMessage());
}
ajax.put("uuid", uuid);
ajax.put("img", Base64.encode(os.toByteArray()));
return ajax;
}
这个AjaxResult
就是后端给前端返回的数据对象,通常称为VO或ResultVO或R(前端与后端交互时的统一数据模型)。
2.2 登陆
前端实现
登陆的前端实现和验证码一样,依旧使用了前端反向代理。
登陆功能的前端实现主要是由handleLogin()
方法实现的。
handleLogin() {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true;
if (this.loginForm.rememberMe) {
Cookies.set("username", this.loginForm.username, { expires: 30 });
Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 });
Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 });
} else {
Cookies.remove("username");
Cookies.remove("password");
Cookies.remove('rememberMe');
}
this.$store.dispatch("Login", this.loginForm).then(() => {
this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
}).catch(() => {
this.loading = false;
if (this.captchaOnOff) {
this.getCode();
}
});
}
});
}
- 登陆使用的是表单,有“记住密码”功能,如果勾选,则将用户名密码和记住我选项存入cookie中,否则移除。
- 其中登陆是由
Login
实现,它是一个action;获取到用户信息后构建并返回一个Promise,它是es6提供的异步处理的对象。
actions: {
// 登录
Login({ commit }, userInfo) {
const username = userInfo.username.trim()
const password = userInfo.password
const code = userInfo.code
const uuid = userInfo.uuid
return new Promise((resolve, reject) => {
login(username, password, code, uuid).then(res => {
setToken(res.token)
commit('SET_TOKEN', res.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
- 后端校验成功后,将后端返回的token保存起来(令牌是加密后的用户信息)
- 其中
login
方法又是封装定义好的方法…,最终还是ajax
// 登录方法
export function login(username, password, code, uuid) {
const data = {
username,
password,
code,
uuid
}
return request({
url: '/login',
headers: {
isToken: false
},
method: 'post',
data: data
})
}
后端实现
控制层
登陆的后端实现在ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java
,逻辑很简单:生成需要返回的AjaxResult
对象,调用Service层的login方法(需要username,password和验证码),生成令牌,放入ajax返回。
关于密码加密:密码是不会在前端或者后端中加密(传输时https协议会进行加密解密;http不会),而是在数据库中(持久层)进行加密存储。
/**
* 登录方法
*
* @param loginBody 登录信息
* @return 结果
*/
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
ajax.put(Constants.TOKEN, token);
return ajax;
}
业务层
大体流程与验证码生成类似:首先验证验证码,然后验证账号和密码。
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid)
{
boolean captchaOnOff = configService.selectCaptchaOnOff();
// 验证码开关
if (captchaOnOff)
{
validateCaptcha(username, code, uuid);
}
// 用户验证
Authentication authentication = null;
try
{
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
// 其他任何异常
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
// 记录用户登陆日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 记录用户最近登陆信息
recordLoginInfo(loginUser.getUserId());
// 生成token
return tokenService.createToken(loginUser);
}
前后端不分离版使用的安全框架时Shiro;而分离版使用的是Spring Security。
注意最后的recordLoginInfo
方法:记录用户最近的登陆信息。后台的数据表会记录用户登陆的ip和时间。
登陆的日志信息会存入sys_logininfor
用户最近登陆信息则存入(更新)sys_user
/**
* 校验验证码
*
* @param username 用户名
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public void validateCaptcha(String username, String code, String uuid)
{
// 拼接Redis的key值
String verifyKey = Constants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
// 去Redis中验证key值是否存在(可能因为长时间未使用而过期)
String captcha = redisCache.getCacheObject(verifyKey);
// key已经使用过,及时删除
redisCache.deleteObject(verifyKey);
if (captcha == null)
{
// 如果key不存在,则“异步记录日志”
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
// 抛出异常
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha))
{
// 如果code(value)验证不正确,同样异步记录日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
throw new CaptchaException();
}
}
- 代码亮点/难点就是使用了异步任务管理器(后面会说),当redis的key不存在时,异步记录日志。这么做的好处就是用到了异步分离,可以避免线程阻塞,让主线程运行快一些(但日志使用异步的意义不大)。
- 关于抛出异常:大型项目和复杂业务直接return的情况少(理想状况才是直接return),通常都是自定义异常处理给系统抓取,前端展示异常信息。而如果只用return(包括错误情况),那么上层的调用方还需要对return的值作判断处理;而使用异常当前调用直接结束,相当于短路处理。
2.3 获取用户角色和权限
前端实现
通过查看浏览器的请求可以发现每次登陆除了login
还有getInfo
和getRouters
这两个请求可以在ruoyi-ui/src/permission.js
中找到:
router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
/* has token*/
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) {
isRelogin.show = true
// 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(() => {
isRelogin.show = false
store.dispatch('GenerateRoutes').then(accessRoutes => {
// 根据roles权限生成可访问的路由表
router.addRoutes(accessRoutes) // 动态添加可访问路由表
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
})
}).catch(err => {
store.dispatch('LogOut').then(() => {
Message.error(err)
next({ path: '/' })
})
})
} else {
next()
}
}
...
上面这段代码的意思是:前端每个页面进行跳转时,都会进入到这个方法(获取信息、路由…),是Vue router的请求拦截器(全局路由管理器、路由前置守卫)
进一步查看GetInfo
方法:
// 获取用户信息
GetInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getInfo().then(res => {
const user = res.user
const avatar = (user.avatar == "" || user.avatar == null) ? require("@/assets/images/profile.jpg") : process.env.VUE_APP_BASE_API + user.avatar;
if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
commit('SET_ROLES', res.roles)
commit('SET_PERMISSIONS', res.permissions)
} else {
commit('SET_ROLES', ['ROLE_DEFAULT'])
}
commit('SET_NAME', user.userName)
commit('SET_AVATAR', avatar)
resolve(res)
}).catch(error => {
reject(error)
})
})
},
发现里面还是封装了一个Promise
对象,进行异步调用。
commit
是对roles和permissions进行全局存储,这样之后在页面内就可以直接使用,而不用每次都进行查询。
同时里面又封装了getInfo
方法:
// 获取用户详细信息
export function getInfo() {
return request({
url: '/getInfo',
method: 'get'
})
}
后端实现
这里集成了Spring Security,可以直接获取当前登陆的user。
/**
* 获取用户信息
*
* @return 用户信息
*/
@GetMapping("getInfo")
public AjaxResult getInfo()
{
SysUser user = SecurityUtils.getLoginUser().getUser();
// 角色集合
Set<String> roles = permissionService.getRolePermission(user);
// 权限集合
Set<String> permissions = permissionService.getMenuPermission(user);
AjaxResult ajax = AjaxResult.success();
ajax.put("user", user);
ajax.put("roles", roles);
ajax.put("permissions", permissions);
return ajax;
}
测试获取角色和权限
查看数据库中表关系可以发现:
- 每个用户有一个user_id
- 每个角色对应一个role_id
- 第三张中间表维护user_id和role_id的对应关系,多对多
2.4 获取动态菜单路由
前面分析了GetInfo
,下面分析GenerateRoutes
,看看Ruoyi是怎么动态获取菜单路由的。
router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
/* has token*/
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) {
isRelogin.show = true
// 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(() => {
isRelogin.show = false
store.dispatch('GenerateRoutes').then(accessRoutes => {
// 根据roles权限生成可访问的路由表
router.addRoutes(accessRoutes) // 动态添加可访问路由表
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
})
}).catch(err => {
store.dispatch('LogOut').then(() => {
Message.error(err)
next({ path: '/' })
})
})
} else {
next()
}
}
...
进入GenerateRoutes
方法,
actions: {
// 生成路由
GenerateRoutes({ commit }) {
return new Promise(resolve => {
// 向后端请求路由数据
getRouters().then(res => {
const sdata = JSON.parse(JSON.stringify(res.data))
const rdata = JSON.parse(JSON.stringify(res.data))
const sidebarRoutes = filterAsyncRouter(sdata)
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes);
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
router.addRoutes(asyncRoutes);
commit('SET_ROUTES', rewriteRoutes)
commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes))
commit('SET_DEFAULT_ROUTES', sidebarRoutes)
commit('SET_TOPBAR_ROUTES', sidebarRoutes)
resolve(rewriteRoutes)
})
})
}
}
// 获取路由
export const getRouters = () => {
return request({
url: '/getRouters',
method: 'get'
})
}
后端Controller逻辑和前面功能一样,依旧使用SpringSecurity获取用户(用户id),然后通过菜单service获取权限对应的菜单,最终通过Ajax对象返回:
/**
* 获取路由信息
*
* @return 路由信息
*/
@GetMapping("getRouters")
public AjaxResult getRouters()
{
Long userId = SecurityUtils.getUserId();
List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
return AjaxResult.success(menuService.buildMenus(menus));
}
service:
/**
* 根据用户ID查询菜单
*
* @param userId 用户名称
* @return 菜单列表
*/
@Override
public List<SysMenu> selectMenuTreeByUserId(Long userId)
{
List<SysMenu> menus = null;
if (SecurityUtils.isAdmin(userId))
{
menus = menuMapper.selectMenuTreeAll();
}
else
{
menus = menuMapper.selectMenuTreeByUserId(userId);
}
return getChildPerms(menus, 0);
}
mapper:
<select id="selectMenuTreeByUserId" parameterType="Long" resultMap="SysMenuResult">
select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.component, m.`query`, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.is_cache, m.menu_type, m.icon, m.order_num, m.create_time
from sys_menu m
left join sys_role_menu rm on m.menu_id = rm.menu_id
left join sys_user_role ur on rm.role_id = ur.role_id
left join sys_role ro on ur.role_id = ro.role_id
left join sys_user u on ur.user_id = u.user_id
where u.user_id = #{userId} and m.menu_type in ('M', 'C') and m.status = 0 AND ro.status = 0
order by m.parent_id, m.order_num
</select>
以上就是以树形嵌套结构查询出的结果
注意mapper层并未实现嵌套关系,而是在Java层面实现(使用递归实现;但是个人感觉使用Map存储是最快的;当然如果把这个工作交给前端来实现也是完全可以的),这里的父子菜单嵌套非常类似我之前写的博客的父子评论嵌套设计(树形结构):博客-评论系统数据库设计及实现
/**
* 根据父节点的ID获取所有子节点
*
* @param list 分类表
* @param parentId 传入的父节点ID
* @return String
*/
public List<SysMenu> getChildPerms(List<SysMenu> list, int parentId)
{
List<SysMenu> returnList = new ArrayList<SysMenu>();
for (Iterator<SysMenu> iterator = list.iterator(); iterator.hasNext();)
{
SysMenu t = (SysMenu) iterator.next();
// 一、根据传入的某个父节点ID,遍历该父节点的所有子节点
if (t.getParentId() == parentId)
{
recursionFn(list, t);
returnList.add(t);
}
}
return returnList;
}
/**
* 递归列表
*
* @param list
* @param t
*/
private void recursionFn(List<SysMenu> list, SysMenu t)
{
// 得到子节点列表
List<SysMenu> childList = getChildList(list, t);
t.setChildren(childList);
for (SysMenu tChild : childList)
{
if (hasChild(list, tChild))
{
recursionFn(list, tChild);
}
}
}
3. 数据加载
3.1 首页数据加载
前端有路由控制,登陆完成后跳转到/
即index主页面:
this.$store.dispatch("Login", this.loginForm).then(() => {
this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
}).catch(() => {
this.loading = false;
if (this.captchaOnOff) {
this.getCode();
}
});
关于Vue路由的控制都在ruoyi-ui/src/router/index.js
:
// index主页面
{
path: '',
component: Layout,
redirect: 'index',
children: [
{
path: 'index',
component: () => import('@/views/index'),
name: 'Index',
meta: { title: '首页', icon: 'dashboard', affix: true }
}
]
},
关于Vue的页面布局在ruoyi-ui/src/layout/index.vue
:
<template>
<div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
<sidebar v-if="!sidebar.hide" class="sidebar-container" />
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">
<div :class="{'fixed-header':fixedHeader}">
<navbar />
<tags-view v-if="needTagsView" />
</div>
<app-main />
<right-panel>
<settings />
</right-panel>
</div>
</div>
</template>
...
3.2 用户管理(PageHelper分页)
这里省略前端的代码,直接贴后端的处理
Controller:
- 设置分页
- 进行正常的查询(PageHelper中的拦截器会拦截数据库sql查询语句,并加入分页的sql语句,完成分页)
- 把查询结果封装后返回
/**
* 获取用户列表
*/
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user)
{
startPage(); // 设置请求分页数据
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list); // 封装返回结果
}
/**
* 响应请求分页数据
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
protected TableDataInfo getDataTable(List<?> list)
{
TableDataInfo rspData = new TableDataInfo();
rspData.setCode(HttpStatus.SUCCESS);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(new PageInfo(list).getTotal());
return rspData;
}
上面代码本质与直接使用PageHelper一样,但是作者进行了非常多的抽象与封装,包括把PageHelper
封装成自己的工具类PageUtils
,把返回的结果封装成TableDataInfo
(类似于Map)。
/**
* 设置请求分页数据
*/
public static void startPage()
{
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
Boolean reasonable = pageDomain.getReasonable();
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}
下面封装非常多,这里就不一一分析,主要看我们是怎么获取到前端传递过来的pageNum
和pageSize
的:从HttpServletRequest
中获取(作者又在这里对Servlet的工具类进行封装)
public class TableSupport
{
/**
* 当前记录起始索引
*/
public static final String PAGE_NUM = "pageNum";
/**
* 每页显示记录数
*/
public static final String PAGE_SIZE = "pageSize";
/**
* 排序列
*/
public static final String ORDER_BY_COLUMN = "orderByColumn";
/**
* 排序的方向 "desc" 或者 "asc".
*/
public static final String IS_ASC = "isAsc";
/**
* 分页参数合理化
*/
public static final String REASONABLE = "reasonable";
/**
* 封装分页对象
*/
public static PageDomain getPageDomain()
{
PageDomain pageDomain = new PageDomain();
pageDomain.setPageNum(Convert.toInt(ServletUtils.getParameter(PAGE_NUM), 1));
pageDomain.setPageSize(Convert.toInt(ServletUtils.getParameter(PAGE_SIZE), 10));
pageDomain.setOrderByColumn(ServletUtils.getParameter(ORDER_BY_COLUMN));
pageDomain.setIsAsc(ServletUtils.getParameter(IS_ASC));
pageDomain.setReasonable(ServletUtils.getParameterToBool(REASONABLE));
return pageDomain;
}
public static PageDomain buildPageRequest()
{
return getPageDomain();
}
}
3.3 部分树状图
跟菜单列表是一样的逻辑
/**
* 获取部门下拉树列表
*/
@GetMapping("/treeselect")
public AjaxResult treeselect(SysDept dept)
{
List<SysDept> depts = deptService.selectDeptList(dept);
return AjaxResult.success(deptService.buildDeptTreeSelect(depts));
}
这里特别注意buildTreeSelect
方法,他把查询到的Tree中的SysDept
通过stream
转化成TreeSelect
结构,即从后端数据转化成给前端显示的数据。
/**
* 构建前端所需要下拉树结构
*
* @param depts 部门列表
* @return 下拉树结构列表
*/
@Override
public List<TreeSelect> buildDeptTreeSelect(List<SysDept> depts)
{
List<SysDept> deptTrees = buildDeptTree(depts);
return deptTrees.stream().map(TreeSelect::new).collect(Collectors.toList());
}
从包含很多无关信息的dept
:
变到了只剩部门id、部名名和children的树形结构,这样前端解析就很方便和舒服
4. 用户增删查改
增删查改比较基础这里不作详细记录。
在前端的表单里,因为逻辑简单,用户新增和修改是使用同一张表单,判断具体是新增还是修改的条件是是否有userId
字段。
/** 提交按钮 */
submitForm: function() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.userId != undefined) {
updateUser(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
});
} else {
addUser(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
}
}
});
},
而后端的处理则是将修改和新增分开
5. 异步任务管理器
以登陆时账号密码不匹配为例:
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
通过异步任务管理器记录(登陆)日志:
AsyncManager.me()
获取一个AsyncManager
对象(单例模式)- 执行
execute
方法,执行任务,传入的是一个TimerTask
对象
/**
* 执行任务
*
* @param task 任务
*/
public void execute(TimerTask task)
{
executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
}
- 而
TimerTask
对象实现了Runnable
接口,是一个任务,由一个线程Thread去执行,注意这里的executors
就是一个线程池
/**
* 异步操作任务调度线程池
*/
private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
总结:
异步任务管理器,内部定义了一个线程池,然后根据业务,创建添加日志的任务,交给线程池来执行,这样就做到了日志和业务的抽象与解耦合。
6. 代码自动生成
B站视频
Ruoyi提供了代码自动生成功能(MyBatisPlus只能自动生成后端代码,Ruoyi生成前后端+数据库的代码),我们只需要在数据库创建数据表,在管理页面即可自动生成增删改查的代码。
从Ruoyi为我们生成的代码文件结构也可以很清楚的了解到整个项目的结构(前后端+数据库)
- 先在mysql数据库中创建一张实体类的表
test_user
use `ruoyi-vue`;
create table test_user(
id int primary key auto_increment,
name varchar(20),
password varchar(20)
);
- 在后台页面的系统工具->代码生成->导入新表
3. 预览代码,Ruoyi自动为我们生成了从数据库到后端到前端的代码
5. 编辑:基本信息、字段信息、生成信息
6. 点击生成代码,生成zip压缩包,解压复制到Ruoyi项目中,解压后可以看到三部分:main(Java后端), vue(Vue前端), sql(菜单的SQL语句)
7. CV导入代码,执行sql,重启(rebuild后端项目)前后端项目。可以看到“测试代码生成”页面,拥有基本的增删查改