使用Redis+AOP優化權限管理功能

之前有很多朋友提過,mall項目中的權限管理功能有性能問題,因爲每次訪問接口進行權限校驗時都會從數據庫中去查詢用戶信息。最近對這個問題進行了優化,通過Redis+AOP解決了該問題,下面來講下我的優化思路。

問題重現

在mall-security模塊中有一個過濾器,當用戶登錄後,請求會帶着token經過這個過濾器。這個過濾器會根據用戶攜帶的token進行類似免密登錄的操作,其中有一步會從數據庫中查詢登錄用戶信息,下面是這個過濾器類的代碼。

/**
 * JWT登錄授權過濾器
 * Created by macro on 2018/4/26.
 */
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.tokenHeader);
        if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
            String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
            String username = jwtTokenUtil.getUserNameFromToken(authToken);
            LOGGER.info("checking username:{}", username);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                //此處會從數據庫中獲取登錄用戶信息
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    LOGGER.info("authenticated user:{}", username);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}

當我們登錄後訪問任意接口時,控制檯會打印如下日誌,表示會從數據庫中查詢用戶信息和用戶所擁有的資源信息,每次訪問接口都觸發這種操作,有的時候會帶來一定的性能問題。

2020-03-17 16:13:02.623 DEBUG 4544 --- [nio-8081-exec-2] c.m.m.m.UmsAdminMapper.selectByExample   : ==>  Preparing: select id, username, password, icon, email, nick_name, note, create_time, login_time, status from ums_admin WHERE ( username = ? )
2020-03-17 16:13:02.624 DEBUG 4544 --- [nio-8081-exec-2] c.m.m.m.UmsAdminMapper.selectByExample   : ==> Parameters: admin(String)
2020-03-17 16:13:02.625 DEBUG 4544 --- [nio-8081-exec-2] c.m.m.m.UmsAdminMapper.selectByExample   : <==      Total: 1
2020-03-17 16:13:02.628 DEBUG 4544 --- [nio-8081-exec-2] c.macro.mall.dao.UmsRoleDao.getMenuList  : ==>  Preparing: SELECT m.id id, m.parent_id parentId, m.create_time createTime, m.title title, m.level level, m.sort sort, m.name name, m.icon icon, m.hidden hidden FROM ums_admin_role_relation arr LEFT JOIN ums_role r ON arr.role_id = r.id LEFT JOIN ums_role_menu_relation rmr ON r.id = rmr.role_id LEFT JOIN ums_menu m ON rmr.menu_id = m.id WHERE arr.admin_id = ? AND m.id IS NOT NULL GROUP BY m.id
2020-03-17 16:13:02.628 DEBUG 4544 --- [nio-8081-exec-2] c.macro.mall.dao.UmsRoleDao.getMenuList  : ==> Parameters: 3(Long)
2020-03-17 16:13:02.632 DEBUG 4544 --- [nio-8081-exec-2] c.macro.mall.dao.UmsRoleDao.getMenuList  : <==      Total: 24

使用Redis作爲緩存

對於上面的問題,最容易想到的就是把用戶信息和用戶資源信息存入到Redis中去,避免頻繁查詢數據庫,本文的優化思路大體也是這樣的。

首先我們需要對Spring Security中獲取用戶信息的方法添加緩存,我們先來看下這個方法執行了哪些數據庫查詢操作。

/**
 * UmsAdminService實現類
 * Created by macro on 2018/4/26.
 */
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
    @Override
    public UserDetails loadUserByUsername(String username){
        //獲取用戶信息
        UmsAdmin admin = getAdminByUsername(username);
        if (admin != null) {
            //獲取用戶的資源信息
            List<UmsResource> resourceList = getResourceList(admin.getId());
            return new AdminUserDetails(admin,resourceList);
        }
        throw new UsernameNotFoundException("用戶名或密碼錯誤");
    }
}

主要是獲取用戶信息和獲取用戶的資源信息這兩個操作,接下來我們需要給這兩個操作添加緩存操作,這裏使用的是RedisTemple的操作方式。當查詢數據時,先去Redis緩存中查詢,如果Redis中沒有,再從數據庫查詢,查詢到以後在把數據存儲到Redis中去。

/**
 * UmsAdminService實現類
 * Created by macro on 2018/4/26.
 */
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
    //專門用來操作Redis緩存的業務類
    @Autowired
    private UmsAdminCacheService adminCacheService;
    @Override
    public UmsAdmin getAdminByUsername(String username) {
        //先從緩存中獲取數據
        UmsAdmin admin = adminCacheService.getAdmin(username);
        if(admin!=null) return  admin;
        //緩存中沒有從數據庫中獲取
        UmsAdminExample example = new UmsAdminExample();
        example.createCriteria().andUsernameEqualTo(username);
        List<UmsAdmin> adminList = adminMapper.selectByExample(example);
        if (adminList != null && adminList.size() > 0) {
            admin = adminList.get(0);
            //將數據庫中的數據存入緩存中
            adminCacheService.setAdmin(admin);
            return admin;
        }
        return null;
    }
    @Override
    public List<UmsResource> getResourceList(Long adminId) {
        //先從緩存中獲取數據
        List<UmsResource> resourceList = adminCacheService.getResourceList(adminId);
        if(CollUtil.isNotEmpty(resourceList)){
            return  resourceList;
        }
        //緩存中沒有從數據庫中獲取
        resourceList = adminRoleRelationDao.getResourceList(adminId);
        if(CollUtil.isNotEmpty(resourceList)){
            //將數據庫中的數據存入緩存中
            adminCacheService.setResourceList(adminId,resourceList);
        }
        return resourceList;
    }
}

上面這種查詢操作其實用Spring Cache來操作更簡單,直接使用@Cacheable即可實現,爲什麼還要使用RedisTemplate來直接操作呢?因爲作爲緩存,我們所希望的是,如果Redis宕機了,我們的業務邏輯不會有影響,而使用Spring Cache來實現的話,當Redis宕機以後,用戶的登錄等種種操作就會都無法進行了。
由於我們把用戶信息和用戶資源信息都緩存到了Redis中,所以當我們修改用戶信息和資源信息時都需要刪除緩存中的數據,具體什麼時候刪除,查看緩存業務類的註釋即可。

/**
 * 後臺用戶緩存操作類
 * Created by macro on 2020/3/13.
 */
public interface UmsAdminCacheService {
    /**
     * 刪除後臺用戶緩存
     */
    void delAdmin(Long adminId);

    /**
     * 刪除後臺用戶資源列表緩存
     */
    void delResourceList(Long adminId);

    /**
     * 當角色相關資源信息改變時刪除相關後臺用戶緩存
     */
    void delResourceListByRole(Long roleId);

    /**
     * 當角色相關資源信息改變時刪除相關後臺用戶緩存
     */
    void delResourceListByRoleIds(List<Long> roleIds);

    /**
     * 當資源信息改變時,刪除資源項目後臺用戶緩存
     */
    void delResourceListByResource(Long resourceId);
}

經過上面的一系列優化之後,性能問題解決了。但是引入新的技術之後,新的問題也會產生,比如說當Redis宕機以後,我們直接就無法登錄了,下面我們使用AOP來解決這個問題。

使用AOP處理緩存操作異常

爲什麼要用AOP來解決這個問題呢?因爲我們的緩存業務類UmsAdminCacheService已經寫好了,要保證緩存業務類中的方法執行不影響正常的業務邏輯,就需要在所有方法中添加try catch邏輯。使用AOP,我們可以在一個地方寫上try catch邏輯,然後應用到所有方法上去。試想下,我們如果又多了幾個緩存業務類,只要配置下切面即可,這波操作多方便!

首先我們先定義一個切面,在相關緩存業務類上面應用,在它的環繞通知中直接處理掉異常,保障後續操作能執行。

/**
 * Redis緩存切面,防止Redis宕機影響正常業務邏輯
 * Created by macro on 2020/3/17.
 */
@Aspect
@Component
@Order(2)
public class RedisCacheAspect {
    private static Logger LOGGER = LoggerFactory.getLogger(RedisCacheAspect.class);

    @Pointcut("execution(public * com.macro.mall.portal.service.*CacheService.*(..)) || execution(public * com.macro.mall.service.*CacheService.*(..))")
    public void cacheAspect() {
    }

    @Around("cacheAspect()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = null;
        try {
            result = joinPoint.proceed();
        } catch (Throwable throwable) {
            LOGGER.error(throwable.getMessage());
        }
        return result;
    }

}

這樣處理之後,就算我們的Redis宕機了,我們的業務邏輯也能正常執行。
不過並不是所有的方法都需要處理異常的,比如我們的驗證碼存儲,如果我們的Redis宕機了,我們的驗證碼存儲接口需要的是報錯,而不是返回執行成功。
對於上面這種需求我們可以通過自定義註解來完成,首先我們自定義一個CacheException註解,如果方法上面有這個註解,發生異常則直接拋出。

/**
 * 自定義註解,有該註解的緩存方法會拋出異常
 */
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheException {
}

之後需要改造下我們的切面類,對於有@CacheException註解的方法,如果發生異常直接拋出。

/**
 * Redis緩存切面,防止Redis宕機影響正常業務邏輯
 * Created by macro on 2020/3/17.
 */
@Aspect
@Component
@Order(2)
public class RedisCacheAspect {
    private static Logger LOGGER = LoggerFactory.getLogger(RedisCacheAspect.class);

    @Pointcut("execution(public * com.macro.mall.portal.service.*CacheService.*(..)) || execution(public * com.macro.mall.service.*CacheService.*(..))")
    public void cacheAspect() {
    }

    @Around("cacheAspect()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
        Object result = null;
        try {
            result = joinPoint.proceed();
        } catch (Throwable throwable) {
            //有CacheException註解的方法需要拋出異常
            if (method.isAnnotationPresent(CacheException.class)) {
                throw throwable;
            } else {
                LOGGER.error(throwable.getMessage());
            }
        }
        return result;
    }

}

接下來我們需要把@CacheException註解應用到存儲和獲取驗證碼的方法上去,這裏需要注意的是要應用在實現類上而不是接口上,因爲isAnnotationPresent方法只能獲取到當前方法上的註解,而不能獲取到它實現接口方法上的註解。

/**
 * UmsMemberCacheService實現類
 * Created by macro on 2020/3/14.
 */
@Service
public class UmsMemberCacheServiceImpl implements UmsMemberCacheService {
    @Autowired
    private RedisService redisService;
    
    @CacheException
    @Override
    public void setAuthCode(String telephone, String authCode) {
        String key = REDIS_DATABASE + ":" + REDIS_KEY_AUTH_CODE + ":" + telephone;
        redisService.set(key,authCode,REDIS_EXPIRE_AUTH_CODE);
    }

    @CacheException
    @Override
    public String getAuthCode(String telephone) {
        String key = REDIS_DATABASE + ":" + REDIS_KEY_AUTH_CODE + ":" + telephone;
        return (String) redisService.get(key);
    }
}

總結

對於影響性能的,頻繁查詢數據庫的操作,我們可以通過Redis作爲緩存來優化。緩存操作不該影響正常業務邏輯,我們可以使用AOP來統一處理緩存操作中的異常。

項目源碼地址

https://github.com/macrozheng/mall

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章