年结功能实现(三)- SpringBoot多数据源 Shiro动态过滤链整合处理

需求:

实现年结功能

实现

  1. SpringBoot整合Shiro实现动态过滤链
    参看Shiro实现动态权限管理

  2. Shiro动态过滤链加载前载入动态数据源

上一节说到自定义Runner 实现ApplicationRunner接口达成系统启动完成后初始化动态数据源
由于Shiro动态过滤链实在系统启动过程中加载的,所以我们在这里必须要保证动态数据源在Shiro之前加载

  • 自定义Configuration类,
import cn.apcinfo.sys.pojo.AnnualInfo;
import cn.apcinfo.sys.service.AnnualService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
public class InitDynamicDataSourceListener {

    private static final Logger log = LoggerFactory.getLogger(InitDynamicDataSourceListener.class);

    @Autowired
    private AnnualService annualService;

	// 由于Spring中不允许存在相同名称的Bean,此处指定Bean的名称,防止因Bean名称冲突导致系统报错
    @Bean(name = "initDynamicDataSourceSonListener")
    public InitDynamicDataSourceListener initDynamicDataSourceListener() {
        // 获取待注册数据源Bean的年度信息
        List<AnnualInfo> annualInfoList = annualService.list();
		
		// 注意: 可以在此处先注册已存在的数据源,由于系统内需要在年结操作完成后动态添加数据源,所以注册数据源的工作未放在此处处理
		//        // 注册各年度数据源
		//        for(AnnualInfo annualInfo:annualInfoList){
		//            ManualRegisteredDsBeanUtil.register((ConfigurableApplicationContext) applicationContext,annualInfo,properties);
		//        }
		//
		//        // 验证Bean是否已注册成功
		//        Map beans = applicationContext.getBeansOfType(DruidDataSource.class);
		//        if(beans.size() != annualInfoList.size()+1){
		//            // 抛出系统运行错误,中止应用启动
		//            throw new Error("Application Start Failed: DataSource Bean Register Failed,Please Check DataSource Configuration is valid!");
		//        }

        return new InitDynamicDataSourceListener();
    }
}
  • 通过依赖注入保证配置类的执行顺序,这里主要保证动态数据源的注册在Shiro之前
/**
 * Shiro 权限认证配置类
 */
@Configuration
public class ShiroConfiguration {

    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager manager,
                                              AuthenticateService authenticateService,
                                              AnnualService annualService,
                                              // 只是为了保证Bean初始化的顺序
                                              @Qualifier("initDynamicDataSourceSonListener") InitDynamicDataSourceListener initDynamicDataSourceListener) {
        ShiroPermissionFilterFactoryBean shiroFilterFactoryBean = new ShiroPermissionFilterFactoryBean();
        // 此处代码省略... 
        return shiroFilterFactoryBean;
    }
}
  • 修改动态注册Bean工具类,只保留修改的部分,其余部分代码参看上一篇
/**
 * Druid DataSource Bean 注册工具类
 */
public class ManualRegisteredDsBeanUtil {

    /**
     * 主动向Spring容器中注册bean
     * @param applicationContext Spring容器
     * @param annualInfo DataSource 年度信息
     * @return 返回容器中DataSourceBean的名称及Bean的实例集合
     */
    public static Map<String,DruidDataSource> register(
            ConfigurableApplicationContext applicationContext,
            AnnualInfo annualInfo,
            DbConnProperties dbConnProperties) {
        // 省略部分代码...

        Map<String,DruidDataSource> result = new HashMap<>(1);

        // 检查DataSource Bean是否存在,如果存在则直接推出
        if(applicationContext.containsBean(dataSourceBeanName)) {
            Object bean = applicationContext.getBean(dataSourceBeanName);
            if (bean.getClass().isAssignableFrom(DruidDataSource.class)) {
                result.put(dataSourceBeanName, (DruidDataSource) bean);
                return result;
            } else {
                throw new RuntimeException("Bean 名称重复,该Bean不是DataSource Bean!");
            }
        }
		
		// 省略部分代码...

        result.put(dataSourceBeanName,druidDataSource);

        return result;
    }
}
  • 修改动态数据源类,实现动态数据源注册,其余部分代码请参看上一篇
public class DynamicDataSource extends AbstractRoutingDataSource implements ApplicationContextAware {

    private static final Logger log = LoggerFactory.getLogger(DynamicDataSource.class);
    /** 缓存 */
    private String ANNUAL_INFO_CACHE_NAME = "annual_year";
    private Cache<String, List<AnnualInfo>> annualInfoCache;
    private DbConnProperties properties;

    public DynamicDataSource(CacheManager cacheManager, DbConnProperties properties){
        super();
        this.annualInfoCache = cacheManager.getCache("annualInfoCache");
        this.properties = properties;
    }

    /**
     * Spring 上下文
     */
    private ApplicationContext applicationContext;

    /**
     * 每一次执行SQL语句之前都会执行此方法,以确定本次访问数据库所对应的数据源的key
     * 这里将数据源的bean name作为数据源的key 以方便后续调度数据数据源
     * @return
     */
    @Override
    protected Object determineCurrentLookupKey() {

        try {
            List<AnnualInfo> annualInfoList = annualInfoCache.get(ANNUAL_INFO_CACHE_NAME);
            // 如果年度有更新,则更新当前数据源
            if(getTargetSource().size() != annualInfoList.size()){
                Map<Object,Object> dataSources = new HashMap<Object,Object>();

                for(AnnualInfo annualInfo:annualInfoList){
                    dataSources.putAll(ManualRegisteredDsBeanUtil.register((ConfigurableApplicationContext) applicationContext,annualInfo,properties));
                }
                super.setTargetDataSources(dataSources);

                super.afterPropertiesSet();
            }
        } catch (Exception ex) {
            // ignore
        }

        Object key = DataSourceScheduling.get();
        // 如果没有指定数据源,则使用默认的数据源
        if(key == null){
            key = "baseDataSource";
        }

        return key;
    }
    // 部分代码省略...
}
  1. 解决Shiro权限验证无法指定数据源的问题:

由于Shiro只有在初次访问要求权限的资源时才会加载权限数据,此时无法获取到数据源调度类DataSourceScheduling存储的数据源Bean名称,导致选择了默认数据源,进而出现无法找到数据表的错误

  • 用户认证API:
    @PostMapping("authenticate")
    public ApcResult login(@ApiIgnore HttpServletResponse response,
                           @ApiIgnore HttpSession httpSession,
                           @NotNull(message = "帐号类型不允许为空!") Integer accountType,
                           @NotBlank(message = "用户名不允许为空!") String loginName,
                           @NotBlank(message = "密码不允许为空!") String password,
                           @NotBlank(message = "验证码不允许为空!") String verifyCode,
                           @NotNull(message = "年度不允许为空!") Integer apcAnnualYear) {
        if (!new MathGenerator(1).verify((String) httpSession.getAttribute(VERIFY_CODE_ATTRIBUTE),verifyCode)) {
            throw new ApcServiceException("验证码错误,请重新输入!");
        }

        Subject subject = SecurityUtils.getSubject();

        ApcToken token = new ApcToken(loginName, password, accountType);

        try {
            subject.login(token);

		    // 这里将用户所选择的年度绑定到session中
            subject.getSession().setAttribute("apcAnnualYear",apcAnnualYear);

            userService.updateLastLoginTimeByLoginName(loginName);
            return new ApcResult(userService.findCompleteUserByLoginName(ShiroUtil.getCurrentLoginUserName()));
        } catch (ExcessiveAttemptsException e) {
            Integer tryTimes = ((ApcExcessiveAttemptsException) e).getTryTime();
            throw new ApcServiceException(StrUtil.format("登录失败,当前登录失败次数:{},达到5次账户将被锁定!", tryTimes));
        } catch (LockedAccountException e) {
            throw new ApcServiceException("登录失败,账户已锁定,请联系系统管理员解锁!");
        } catch (Exception e) {
            throw new ApcServiceException("登录失败:您输入的用户名或密码错误,请核对后重试!");
        }
    }
  • 自定义Releam类:
/**
     * 授权
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        // 从session中获取年度参数并根据规则拼接生成javabean的名称设置到数据源调度类
        Integer year = (Integer) SecurityUtils.getSubject().getSession().getAttribute("apcAnnualYear");
        DataSourceScheduling.set("dj" + year + "DataSource");

        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        List<String> menuCodes = authenticateService.listFunctionCodeByLoginName(principalCollection.getPrimaryPrincipal().toString());
        info.addStringPermissions(menuCodes);

        return info;
    }
  1. 年度结算功能操作成功刷新缓存,以保证动态数据源DynamicDataSource类能够及时加载新的数据源
// 清除缓存,重新加载数据源
// 注意,这里不能调用cache.clear()方法清空缓存,clear()只是清空缓存数据集合而不是将缓存数据清空
annualInfoCache.put(ANNUAL_INFO_CACHE_NAME,null);
this.list();
  1. 前端参数处理
    由于后端是通过拦截前端请求从而实现动态数据源切换的,所以前端每次请求都需要传入 年度 参数,本次项目使用Axios,故通过Axios的request interceptors实现
const service = axios.create({
    baseURL: process.env.VUE_APP_BASE_URL,
    timeout: 10000, // 超时时长,单位: ms
    // 跨域携带cookie配置
    withCredentials: true
})

service.interceptors.request.use(
    config => {
        config.headers['X-Requested-With'] = 'XMLHttpRequest';

        if(config.method === 'get'){
            config.params = disposeRequestParam(config.params);
        } else if(config.method === 'post'){
            config.data = disposeRequestParam(config.data);
        }

        return config
    },
    error => {
        console.log(error) // for debug
        return Promise.reject(error)
    }
)

/**
  *  注意:参数可能未被qs序列化(后端使用@RequestBody接收参数的情况),
  *  也有可能已被qs序列化,所以此处要根据实际拿到的参数类型分类处理
  */
function disposeRequestParam(params){
    // 当前无任何参数,将参数修改为空json
    if(params === null || params === undefined){
        params = {};
    }
    // 参数是否为字符串
    let paramsIsString = false;
    // 如果参数已被序列化,将其转为json对象
    if(typeof params === 'string'){
        paramsIsString = true;
        params = qs.parse(params);
    }
    params = Object.assign(params,{ 
        apcAnnualYear: store.state.certified.year
    })

    if(paramsIsString === true){
        return qs.stringify(params)
    } else {
        return params;
    }
}
  1. 另外,后端在获取年度参数时需要做特殊处理,可参看SpringBoot获取请求体中的数据

  2. 多数据库mybatis selectKey报错的解决方案
    由于系统多数据源产生多个类似数据库时,mysql auto_increment查询报错
    问题原因:

SELECT auto_increment FROM information_schema.tables where table_name="t1"

如上SQL表示从Mysql的所有数据库中查询t1表的auto_increment值,如果多个库中存在名称相同的表会导致查询出多个结果
而此时配合Mybatis selectKey使用时,会出现期望结果1,而实际结果多个的错误,如下:

<selectKey resultType="integer" keyColumn="pk_id" keyProperty="id" order="BEFORE">
            SELECT auto_increment FROM information_schema.tables where table_name="t1"
selectKey>

解决方案: 在查询auto_increment时指定数据库名称,sql如下:

SELECT auto_increment FROM information_schema.tables where table_name="t1" AND TABLE_SCHEMA = 'test'

本次项目中,由于动态数据源的原因,此处无法定向指定数据库名称,采用如下迂回方案:

  • 创建seectKey对应实体所需要继承的基类
public class AnnualYearVO {
	// 注意: 前端每一次请求都会传入参数
    private Integer apcAnnualYear;

    public Integer getApcAnnualYear() {
        return apcAnnualYear;
    }

    public void setApcAnnualYear(Integer apcAnnualYear) {
        this.apcAnnualYear = apcAnnualYear;
    }
}
  • 所有使用到了selectKey对应的实体都继承如上基类
  • 修改selectKey,指定库名
<selectKey resultType="integer" keyColumn="pk_id" keyProperty="id" order="BEFORE">
			
            <bind name="apcAnnualYear" value="'%'+apcAnnualYear+'%'" />
            SELECT auto_increment FROM information_schema.tables where table_name="t1" AND TABLE_SCHEMA LIKE #{apcAnnualYear}
selectKey>

注意,如上解决方案只能作为临时解决方案,弊端如下:

  1. 如果Controller传入的参数为List则此方案不适用,需要在服务层做额外处理
  2. 如果库名年度部分出现重复,则模糊查询依然会产生多条结果集,达不到所期望的目的

结语

感谢以下博客提供的解决方案:
Shiro实现动态权限管理
SpringBoot获取请求体中的数据

你可能感兴趣的:(SpringBoot,Mybatis,相关)