优秀开源项目解读(三) - 若依/RuoYi-Cloud开源项目

4,652 阅读7分钟

项目的深入解读是一个很好的项目经验积累的过程,项目解读需要带着以下几个点去进行:

  1. 想学什么
  2. 主要功能是什么
  3. 技术栈有哪些
  4. 项目结构如何
  5. 哪些设计亮点值得学习
  6. 需要解决自身目前遇到哪些问题
  7. 项目结论

本篇文章主要是对RuoYi-Cloud微服务项目进行深入的解读和学习,RuoYi-Cloud是一个基于 Vue/Element UI 和 Spring BootSpring 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的数据库表设计:

RouYi权限关联表分析.jpg

该系统的权限控制主要分为五种:

  1. 全部数据权限,不进行数据过滤
  2. 自定义数据权限
  3. 部门数据权限
  4. 部门以及一下数据权限
  5. 仅当前登录用户个人权限

其实核心实现逻辑采用的是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注解即可,使用很简单。

    1. 实现是使用AOP对方法访问进行计数。根据访问方法的全限定类名+方法名为key,数量为value,进行判断,超过了指定的数量,即触发Exception返回。由于这里步骤较多(自增value,判断value是否超过count)所以使用了Lua脚本保证原子性同时又减少网络开销。
    2. 可以有两种模式,一种是全局,一种是IP。默认是全局只同居类名+方法名,而IP会加上IP前缀,粒度小一些。
  • 使用RepeatSubmit注解,在规定的时间内不能提交第二次,可以理解为限流+脚本防刷

    1. 实现时使用拦截器进行拦截,判断拦截的方法是否有这个注解,有的话,进行判断。
    2. 判断逻辑也是根据redis中获取上次访问的参数(这里较简单就没有使用Lua脚本),如果相同,拦截,否则正常。

多数据源

使用@DataSource注解再指定想要使用哪个数据源即可,默认是配置了Slave和Master两个类型,但是只有一个Master数据源,如果想使用Slave就在配置文件中添加一个数据源。

实现上,是使用了AOP对添加了@DataSource注解的方法或类进行设置,使用的是ThreadLocal和每个线程绑定数据源名称。

在启动系统时,会向IOC容器中,添加一个数据源的Bean,默认是使用的Master名称的数据源,若配置了Slave数据源,则会加载Slave的Bean然后添加到数据源管理中。供系统切换。(实现了一个AbstractRoutingDataSource的方法,返回我们设置的数据源名称即可就可以让数据源容器自动切换)。

防止Xss供给和SQL注入

XSS攻击通常指的是通过利用网页开发时留下的漏洞,通过巧妙的方法注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。这些恶意网页程序通常是JavaScript,但实际上也可以包括Java、 VBScriptActiveX、 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();
    }
}