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
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客户端查看验证码答案。
docker exec -it 6ce bash
redis-cli
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'
const service = axios.create({
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 中:
devServer: {
host: '0.0.0.0',
port: port,
open: true,
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
{
AjaxResult ajax = AjaxResult.success();
boolean captchaOnOff = configService.selectCaptchaOnOff();
ajax.put("captchaOnOff", captchaOnOff);
if (!captchaOnOff)
{
return ajax;
}
String uuid = IdUtils.simpleUUID();
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);
}
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不会),而是在数据库中(持久层)进行加密存储。
@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;
}
业务层
大体流程与验证码生成类似:首先验证验证码,然后验证账号和密码。
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
{
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());
return tokenService.createToken(loginUser);
}
前后端不分离版使用的安全框架时Shiro;而分离版使用的是Spring Security。
注意最后的recordLoginInfo 方法:记录用户最近的登陆信息。后台的数据表会记录用户登陆的ip和时间。
登陆的日志信息会存入sys_logininfor
用户最近登陆信息则存入(更新)sys_user
public void validateCaptcha(String username, String code, String uuid)
{
String verifyKey = Constants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
String captcha = redisCache.getCacheObject(verifyKey);
redisCache.deleteObject(verifyKey);
if (captcha == null)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha))
{
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)
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) {
isRelogin.show = true
store.dispatch('GetInfo').then(() => {
isRelogin.show = false
store.dispatch('GenerateRoutes').then(accessRoutes => {
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
})
}).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) {
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。
@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)
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) {
isRelogin.show = true
store.dispatch('GetInfo').then(() => {
isRelogin.show = false
store.dispatch('GenerateRoutes').then(accessRoutes => {
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
})
}).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对象返回:
@GetMapping("getRouters")
public AjaxResult getRouters()
{
Long userId = SecurityUtils.getUserId();
List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
return AjaxResult.success(menuService.buildMenus(menus));
}
service:
@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存储是最快的;当然如果把这个工作交给前端来实现也是完全可以的),这里的父子菜单嵌套非常类似我之前写的博客的父子评论嵌套设计(树形结构):博客-评论系统数据库设计及实现
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();
if (t.getParentId() == parentId)
{
recursionFn(list, t);
returnList.add(t);
}
}
return returnList;
}
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 :
{
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";
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 结构,即从后端数据转化成给前端显示的数据。
@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 对象
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后端项目)前后端项目。可以看到“测试代码生成”页面,拥有基本的增删查改
|