项目的深入解读是一个很好的项目经验积累的过程,项目解读需要带着以下几个点去进行:
- 想学什么
- 主要功能是什么
- 技术栈有哪些
- 项目结构如何
- 哪些设计亮点值得学习
- 需要解决自身目前遇到哪些问题
- 项目结论
本篇文章主要是对RuoYi-Cloud微服务项目进行深入的解读和学习,RuoYi-Cloud是一个基于 Vue/Element UI 和 Spring Boot、Spring Cloud & Alibaba 前后端分离的分布式微服务项目,采用前后端分离的模式。
核心技术栈
| 框架 | 功能 |
|---|---|
| Spring Cloud Gateway | 服务网关、限流配置、跨域配置 |
| Nacos | 实现服务注册与发现、nacos配置中心 |
| Seata | 分布式事务处理方案 |
| Oauth2.0、Redis | 实现认证授权、权限认证、缓存 |
| Sentinel | 服务熔断降级、限流 |
| OpenFeign | 服务调用及服务间鉴权 |
| Spring Boot Admin | 服务监控管理 |
| ELK | 分布式日志收集 |
| Ribbon | 客户端负载均衡 |
| RabbitMQ、Kafaka | 消息中间件 |
代码结构说明
com.ruoyi
├── ruoyi-ui // 前端框架 [80]
├── ruoyi-gateway // 网关模块 [8080]
├── ruoyi-auth // 认证中心 [9200]
├── ruoyi-api // 接口模块
│ └── ruoyi-api-system // 系统接口
├── ruoyi-common // 通用模块
│ └── ruoyi-common-core // 核心模块
│ └── ruoyi-common-datascope // 权限范围
│ └── ruoyi-common-datasource // 多数据源
│ └── ruoyi-common-log // 日志记录
│ └── ruoyi-common-redis // 缓存服务
│ └── ruoyi-common-security // 安全模块
│ └── ruoyi-common-swagger // 系统接口
├── ruoyi-modules // 业务模块
│ └── ruoyi-system // 系统模块 [9201]
│ └── ruoyi-gen // 代码生成 [9202]
│ └── ruoyi-job // 定时任务 [9203]
│ └── ruoyi-file // 文件服务 [9300]
├── ruoyi-visual // 图形化管理模块
│ └── ruoyi-visual-monitor // 监控中心 [9100]
├──pom.xml // 公共依赖
数据权限
权限管理是这个系统的最主要、最核心的功能,RuoYi也是基于RBAC模型(基于角色的访问控制模型)去实现的,首先看啊看RouYi的数据库表设计:
该系统的权限控制主要分为五种:
- 全部数据权限,不进行数据过滤
- 自定义数据权限
- 部门数据权限
- 部门以及一下数据权限
- 仅当前登录用户个人权限
其实核心实现逻辑采用的是Spring AOP切面来实现的,通过自定义注解@DataScope,注解的具体功能实现在切面DataScopeAspect,分析源码:
DataScope
package com.ruoyi.common.datascope.annotation;
import java.lang.annotation.*;
/**
* 数据权限过滤注解
*
* @author ruoyi
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataScope {
/**
* 部门表的别名
*/
public String deptAlias() default "";
/**
* 用户表的别名
*/
public String userAlias() default "";
}
DataScopeAspect
package com.ruoyi.common.datascope.aspect;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.core.web.domain.BaseEntity;
import com.ruoyi.common.datascope.annotation.DataScope;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.system.api.domain.SysRole;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.model.LoginUser;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
/**
* 数据过滤处理
*
* @author ruoyi
*/
@Aspect
@Component
public class DataScopeAspect {
/**
* 全部数据权限
*/
public static final String DATA_SCOPE_ALL = "1";
/**
* 自定数据权限
*/
public static final String DATA_SCOPE_CUSTOM = "2";
/**
* 部门数据权限
*/
public static final String DATA_SCOPE_DEPT = "3";
/**
* 部门及以下数据权限
*/
public static final String DATA_SCOPE_DEPT_AND_CHILD = "4";
/**
* 仅本人数据权限
*/
public static final String DATA_SCOPE_SELF = "5";
/**
* 数据权限过滤关键字
*/
public static final String DATA_SCOPE = "dataScope";
@Before("@annotation(controllerDataScope)")
public void doBefore(JoinPoint point, DataScope controllerDataScope) throws Throwable {
clearDataScope(point);
handleDataScope(point, controllerDataScope);
}
protected void handleDataScope(final JoinPoint joinPoint, DataScope controllerDataScope) {
// 获取当前的用户
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNotNull(loginUser)) {
SysUser currentUser = loginUser.getSysUser();
// 如果是超级管理员,则不过滤数据
if (StringUtils.isNotNull(currentUser) && !currentUser.isAdmin()) {
dataScopeFilter(joinPoint, currentUser, controllerDataScope.deptAlias(),
controllerDataScope.userAlias());
}
}
}
/**
* 数据范围过滤
*
* @param joinPoint 切点
* @param user 用户
* @param deptAlias 部门别名
* @param userAlias 用户别名
*/
public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String deptAlias, String userAlias) {
StringBuilder sqlString = new StringBuilder();
for (SysRole role : user.getRoles()) {
String dataScope = role.getDataScope();
if (DATA_SCOPE_ALL.equals(dataScope)) {
sqlString = new StringBuilder();
break;
} else if (DATA_SCOPE_CUSTOM.equals(dataScope)) {
sqlString.append(StringUtils.format(
" OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,
role.getRoleId()));
} else if (DATA_SCOPE_DEPT.equals(dataScope)) {
sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
} else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope)) {
sqlString.append(StringUtils.format(
" OR {}.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = {} or find_in_set( {} , ancestors ) )",
deptAlias, user.getDeptId(), user.getDeptId()));
} else if (DATA_SCOPE_SELF.equals(dataScope)) {
if (StringUtils.isNotBlank(userAlias)) {
sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));
} else {
// 数据权限为仅本人且没有userAlias别名不查询任何数据
sqlString.append(" OR 1=0 ");
}
}
}
if (StringUtils.isNotBlank(sqlString.toString())) {
Object params = joinPoint.getArgs()[0];
if (StringUtils.isNotNull(params) && params instanceof BaseEntity) {
BaseEntity baseEntity = (BaseEntity) params;
baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
}
}
}
/**
* 拼接权限sql前先清空params.dataScope参数防止注入
*/
private void clearDataScope(final JoinPoint joinPoint) {
Object params = joinPoint.getArgs()[0];
if (StringUtils.isNotNull(params) && params instanceof BaseEntity) {
BaseEntity baseEntity = (BaseEntity) params;
baseEntity.getParams().put(DATA_SCOPE, "");
}
}
}
以自定义数据权限为栗说明:
if (DATA_SCOPE_CUSTOM.equals(dataScope)) {
sqlString.append(StringUtils.format(
" OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,
role.getRoleId()));
}
可以看到他是通过我们当前登录用户的roleId来进行sql拼接的(一个用户可能会拥有多个role角色,所以这个代码在循环里面,下面例子中仅使用一个role)。
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
<!-- 省略一些if判断 -->
<!-- 数据范围过滤 -->
${params.dataScope}
有了之前的AOP拼接的SQL,在使用mybatis的插值表达式插入到已有SQL中完成数据过滤。
比如这个用户查询,当前登录的用户角色是A,没有过滤前所有用户都能查询出来,然后加入了拼接的SQL后,仅仅返回用户的部门id在100,101,105,110之中的。
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
<!-- 数据范围过滤 -->
AND u.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = A )
这样就完成了对用户查询的数据权限过滤。这是调用的代码。
@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser user)
{
return userMapper.selectUserList(user);
}
其中通过deptAlias、userAlias分别可以指定SQL查询部门表、用户表的别名。
限流&重复提交
限流,是高并发系统中非常重要的事情,意思就是在某个时间内,限制访问的次数只能在固定的范围之内,本质上是为了控制系统的qps,保证系统不被大量的请求打垮,在RouYi-Vue项目中,提供了两个实现,一个是RateLimiter,另一个是RepeatSubmit:
-
使用
@RateLimiter注解即可,使用很简单。- 实现是使用AOP对方法访问进行计数。根据访问方法的
全限定类名+方法名为key,数量为value,进行判断,超过了指定的数量,即触发Exception返回。由于这里步骤较多(自增value,判断value是否超过count)所以使用了Lua脚本保证原子性同时又减少网络开销。 - 可以有两种模式,一种是全局,一种是IP。默认是全局只同居类名+方法名,而IP会加上IP前缀,粒度小一些。
- 实现是使用AOP对方法访问进行计数。根据访问方法的
-
使用
RepeatSubmit注解,在规定的时间内不能提交第二次,可以理解为限流+脚本防刷。- 实现时使用拦截器进行拦截,判断拦截的方法是否有这个注解,有的话,进行判断。
- 判断逻辑也是根据redis中获取上次访问的参数(这里较简单就没有使用Lua脚本),如果相同,拦截,否则正常。
多数据源
使用@DataSource注解再指定想要使用哪个数据源即可,默认是配置了Slave和Master两个类型,但是只有一个Master数据源,如果想使用Slave就在配置文件中添加一个数据源。
实现上,是使用了AOP对添加了@DataSource注解的方法或类进行设置,使用的是ThreadLocal和每个线程绑定数据源名称。
在启动系统时,会向IOC容器中,添加一个数据源的Bean,默认是使用的Master名称的数据源,若配置了Slave数据源,则会加载Slave的Bean然后添加到数据源管理中。供系统切换。(实现了一个AbstractRoutingDataSource的方法,返回我们设置的数据源名称即可就可以让数据源容器自动切换)。
防止Xss供给和SQL注入
XSS攻击通常指的是通过利用网页开发时留下的漏洞,通过巧妙的方法注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。这些恶意网页程序通常是JavaScript,但实际上也可以包括Java、 VBScript、ActiveX、 Flash 或者甚至是普通的HTML。攻击成功后,攻击者可能得到包括但不限于更高的权限(如执行一些操作)、私密网页内容、会话和cookie等各种内容。
SqlUtil
/**
* sql防注入操作工具类
*
*/
public class SqlUtil {
/**
* 仅支持字母、数字、下划线、空格、逗号、小数点(支持多个字段排序)
*/
public static String SQL_PATTERN = "[a-zA-Z0-9_\ \,\.]+";
/**
* 检查字符,防止注入绕过
*/
public static String escapeOrderBySql(String value) {
if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value)) {
throw new BaseException("参数不符合规范,不能进行查询");
}
return value;
}
/**
* 验证 order by 语法是否符合规范
*/
public static boolean isValidOrderBySql(String value) {
return value.matches(SQL_PATTERN);
}
}
Xss
/**
* 自定义xss校验注解
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(value = {ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Constraint(validatedBy = {XssValidator.class})
public @interface Xss {
String message()
default "不允许任何脚本运行";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
自定义校验注解实现XssValidator
/**
* 自定义xss校验注解实现
*
*/
public class XssValidator implements ConstraintValidator<Xss, String> {
private static final String HTML_PATTERN = "<(\S*?)[^>]*>.*?|<.*? />";
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if (StringUtils.isBlank(value)) {
return true;
}
return !containsHtml(value);
}
public static boolean containsHtml(String value) {
Pattern pattern = Pattern.compile(HTML_PATTERN);
Matcher matcher = pattern.matcher(value);
return matcher.matches();
}
}