美文网首页Java 杂谈
Mybatis源码分析(七)自定义缓存、分页的实现

Mybatis源码分析(七)自定义缓存、分页的实现

作者: 清幽之地 | 来源:发表于2018-10-23 17:32 被阅读0次

    上一章节通过源码已经深入了解到插件的加载机制和时机,本章节就实战一下。拿两个功能点来展示插件的使用。

    一、缓存

    我们知道,在Mybatis中是有缓存实现的。分一级缓存和二级缓存,不过一级缓存其实没啥用。因为我们知道它是基于sqlSession的,而sqlSession在每一次的方法执行时都会被新创建。二级缓存是基于namespace,离开了它也是不行。有没有一种方式来提供自定义的缓存机制呢?

    1、Executor

    Executor是Mybatis中的执行器。所有的查询就是调用它的<E> List<E> query()方法。我们就可以在这里进行拦截,不让它执行后面的查询动作, 直接从缓存返回。

    在这个类里面,我们先获取参数中的缓存标记和缓存的Key,去查询Redis。如果命中,则返回;未命中,接着执行它本身的方法。

    @Intercepts({@Signature(method = "query", type = Executor.class,args = {
            MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})})
    //BeanFactoryAware是Spring中的接口。目的是获取jedisService的Bean
    public class ExecutorInterceptor implements Interceptor,BeanFactoryAware{
        private JedisServiceImpl jedisService;
        
        @SuppressWarnings("unchecked")
        public Object intercept(Invocation invocation) throws Throwable {
            if (invocation.getTarget() instanceof CachingExecutor) {
                //获取CachingExecutor所有的参数
                Object[] params = invocation.getArgs();
                //第二个参数就是业务方法的参数
                Map<String,Object> paramMap = (Map<String, Object>) params[1];
                String isCache = paramMap.get("isCache").toString();
                //判断是否需要缓存,并取到缓存的Key去查询Redis
                if (isCache!=null && "true".equals(isCache)) {
                    String cacheKey = paramMap.get("cacheKey").toString();
                    String cacheResult = jedisService.getString(cacheKey);
                    if (cacheResult!=null) {
                        System.out.println("已命中Redis缓存,直接返回.");
                        return JSON.parseObject(cacheResult, new TypeReference<List<Object>>(){});
                    }else {
                        return invocation.proceed();
                    }
                    
                }
                
            }
            return invocation.proceed();
        }
        
        //返回代理对象
        public Object plugin(Object target) {
            if (target instanceof Executor) {
                return Plugin.wrap(target, this);
            }
            return target;
        }
    
        public void setProperties(Properties properties) {}
    
        public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
            jedisService = (JedisServiceImpl) beanFactory.getBean("jedisServiceImpl");
        }
    
    }
    

    以上方法只是从缓存中获取数据,但什么时候往缓存中添加数据呢?总不能在每个业务方法里面调用Redis的方法,以后如果把Redis换成了别的数据库,岂不是很尴尬。

    回忆一下Mybatis执行方法的整个流程。在提交执行完SQL之后,它是怎么获取返回值的呢?

    2、ResultSetHandler

    没有印象吗?就是这句return resultSetHandler.<E> handleResultSets(ps);其中的resultSetHandler就是DefaultResultSetHandler实例的对象。它负责解析并返回从数据库查询到的数据,那么我们就可以在返回之后把它放到Redis。

    @Intercepts({@Signature(method = "handleResultSets", 
            type = ResultSetHandler.class,args = {Statement.class})})
    public class ResultSetHandlerInterceptor implements Interceptor,BeanFactoryAware{
    
        private JedisServiceImpl jedisService;
        @SuppressWarnings("unchecked")
        public Object intercept(Invocation invocation) throws Throwable {
            Object result = null;
            if (invocation.getTarget() instanceof DefaultResultSetHandler) {
                //先执行方法,以获得结果集
                result = invocation.proceed();      
                DefaultResultSetHandler handler = (DefaultResultSetHandler) invocation.getTarget();
                
                //通过反射拿到里面的成员属性,是为了最终拿到业务方法的参数
                Field boundsql_field = getField(handler, "boundSql");
                BoundSql boundSql = (BoundSql)boundsql_field.get(handler);
                Field param_field = getField(boundSql, "parameterObject");
                Map<String,Object> paramMap = (Map<String, Object>) param_field.get(boundSql);
                
                String isCache = paramMap.get("isCache").toString();
                if (isCache!=null && "true".equals(isCache)) {
                    String cacheKey = paramMap.get("cacheKey").toString();
                    String cacheResult = jedisService.getString(cacheKey);
                    //如果缓存中没有数据,就添加进去
                    if (cacheResult==null) {
                        jedisService.setString(cacheKey, JSONObject.toJSONString(result));
                    }
                }
            }
            return result;
        }
        public Object plugin(Object target) {
            if (target instanceof ResultSetHandler) {
                return Plugin.wrap(target, this);
            }
            return target;
        }
        private Field getField(Object obj, String name) {
            Field field = ReflectionUtils.findField(obj.getClass(), name);
            field.setAccessible(true);
            return field;
        }
        public void setProperties(Properties properties) {}
        public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
            jedisService = (JedisServiceImpl) beanFactory.getBean("jedisServiceImpl");
        }
    }
    

    通过这两个拦截器,就可以实现自定义缓存。当然了,处理逻辑还是看自己的业务来定,但大体流程就是这样的。这里面最重要的其实是cacheKey的设计,怎么做到通用性以及唯一性。为什么这样说呢?想象一下,如果执行了UPDATE操作,我们需要清除缓存,那么以什么规则来清除呢?还有,如果cacheKey的粒度太粗,相同查询方法的不同参数值怎么来辨别呢?这都需要深思熟虑来设计这个字段才行。

    public @ResponseBody List<User> queryAll(){
        Map<String,Object> paramMap = new HashMap<>();
        paramMap.put("isCache", "true");
        paramMap.put("cacheKey", "userServiceImpl.getUserList");
        List<User> userList = userServiceImpl.getUserList(paramMap);
        return userList;
    } 
    

    二、分页

    基本每个应用程序都有分页的功能。从数据库的角度来看,分页就是确定从第几条开始,一共取多少条的问题。比如在MySQL中,我们可以这样select * from user limit 0,10

    在程序中,我们不能每个SQL语句都加上limit,万一换了不支持Limit的数据库也是麻烦事。同时,limit后的0和10也并非一成不变的,这个取决于我们的页面逻辑。

    在解析完BoundSql之后,Mybatis开始调用StatementHandler.prepare()方法来构建预编译对象,并设置参数值和提交SQL语句。我们的目的就是在此之前修改BoundSql中的SQL语句。先来看下拦截器的定义。

    @Intercepts({@Signature(type = StatementHandler.class, method = "prepare", 
                            args = {Connection.class,Integer.class})})
    public class PageInterceptor implements Interceptor {
        
        public Object intercept(Invocation invocation) throws Throwable {
            return invocation.proceed();
        }
        public Object plugin(Object target) {
            if (target instanceof RoutingStatementHandler) {
                return Plugin.wrap(target, this);
            }
            return target;
        }
    }
    

    1、Page对象

    那么,第一步,我们先创建一个Page对象。它负责记录和计算数据的起始位置和总条数,以便在页面通过计算来友好的展示分页。

    public class Page {
        public Integer start;//当前页第一条数据在List中的位置,从0开始
        public static final Integer pageSize = 10;//每页的条数
        public Integer totals;//总记录条数
        public boolean needPage;//是否需要分页  
        
        public Page(int pages) {
            setNeedPage(true);
            start = (pages-1)*Page.pageSize;
        }
        public boolean isNeedPage() {
            return needPage;
        }
        public void setNeedPage(boolean needPage) {
            this.needPage = needPage;
        }
    }
    

    2、获取参数

    从目标对象中,拿到各种参数,先要判断是否需要分页

    @Intercepts({@Signature(type = StatementHandler.class, method = "prepare", 
                            args = {Connection.class,Integer.class})})
    public class PageInterceptor implements Interceptor {
        
        public Object intercept(Invocation invocation) throws Throwable {
            if (invocation.getTarget() instanceof StatementHandler) {
                
                StatementHandler statementHandler = (StatementHandler)invocation.getTarget();
                Field delegate_field = getField(statementHandler, "delegate");
                
                StatementHandler preparedHandler = (StatementHandler)delegate_field.get(statementHandler);          
                Field mappedStatement_field = getField(preparedHandler, "mappedStatement");
                
                MappedStatement mappedStatement = (MappedStatement) mappedStatement_field.get(preparedHandler); 
                
                Field boundsql_field = getField(preparedHandler, "boundSql");
                BoundSql boundSql = (BoundSql)boundsql_field.get(preparedHandler);
                
                String sql = boundSql.getSql();
                Object param = boundSql.getParameterObject();
                
                if (param instanceof Map) {
                    Map paramObject = (Map)param;
                    if (paramObject.containsKey("page")) {
                        //判断是否需要分页
                        Page page = (Page)paramObject.get("page");
                        if (!page.isNeedPage()) {
                            return invocation.proceed();
                        }
                        Connection connection = (Connection) invocation.getArgs()[0];
                        setTotals(mappedStatement,preparedHandler,page,connection,boundSql);
                        sql = pageSql(sql, page);
                        Field sql_field = getField(boundSql, "sql");
                        sql_field.setAccessible(true);
                        sql_field.set(boundSql, sql);
                    }
                }
            }
            return invocation.proceed();
        }
    }
    

    3、设置总条数

    实际上,一次分页功能要设计到两次查询。一次是本身的SQL加上Limit标签,一次是不加Limit的标签并且应该是Count语句,来获取总条数。所以,就是涉及到setTotals这个方法。
    这个方法的目的是获取数据的总条数,它涉及几个关键点。

    • 修改原来的SQL,改成Count语句。
    • 修改原来方法的返回值类型。
    • 执行SQL。
    • 把修改后的SQL和返回值类型,再改回去。
    private void setTotals(MappedStatement mappedStatement,StatementHandler preparedHandler,
                            Page page,Connection connection,BoundSql boundSql){
                
        //原来的返回值类型
        Class<?> old_type = Object.class;
        ResultMap resultMap = null;
        List<ResultMap> resultMaps = mappedStatement.getResultMaps();
        if (resultMaps!=null && resultMaps.size()>0) {
            resultMap = resultMaps.get(0);
            old_type = resultMap.getType();
            //修改返回值类型为Integer,因为我们获取的是总条数
            Field type_field = getField(resultMap, "type");
            type_field.setAccessible(true);
            type_field.set(resultMap, Integer.class);
        }
        
        //修改SQL为count语句
        String old_sql = boundSql.getSql();
        String count_sql = getCountSql(old_sql);
        
        Field sql_field = getField(boundSql, "sql");
        sql_field.setAccessible(true);
        sql_field.set(boundSql, count_sql);
        
        //执行SQL 并设置总条数到Page对象
        Statement statement =  prepareStatement(preparedHandler, connection);
        List<Object> resObjects = preparedHandler.query(statement, null);
        int result_count = (int) resObjects.get(0);
        page.setTotals(result_count);
            
        /**
         * 还要把sql和返回类型修改回去,这点很重要
         */
        Field sql_field_t = getField(boundSql, "sql");
        sql_field_t.setAccessible(true);
        sql_field_t.set(boundSql, old_sql);
        
        Field type_field = getField(resultMap, "type");
        type_field.setAccessible(true);
        type_field.set(resultMap, old_type);
    }
    private String getCountSql(String sql) {    
        int index = sql.indexOf("from");    
        return "select count(1) " + sql.substring(index);    
    }
    

    4、Limit

    还获取到总条数之后,还要修改一次SQL,是加上Limit。最后执行,并返回结果。

    String sql = boundSql.getSql();
    
    //加上Limit,从start开始
    sql = pageSql(sql, page);
    Field sql_field = getField(boundSql, "sql");
    sql_field.setAccessible(true);
    sql_field.set(boundSql, sql);
    
    
    private String pageSql(String sql, Page page) {
        StringBuffer sb = new StringBuffer();
        sb.append(sql);
        sb.append(" limit ");
        sb.append(page.getStart());
        sb.append("," + Page.pageSize);
        return sb.toString();
    }
    

    最后,在业务方法里面直接调用即可。当然了,记住要把Page参数传过去。

    public @ResponseBody List<User> queryAll(HttpServletResponse response) throws IOException {
            
        Page page = new Page(1);
        Map<String,Object> paramMap = new HashMap<>();
        paramMap.put("isCache", "true");
        paramMap.put("cacheKey", "userServiceImpl.getUserList");
        paramMap.put("page", page);
        
        List<User> userList = userServiceImpl.getUserList(paramMap);
        for (User user : userList) {
            System.out.println(user.getUsername());
        }
        System.out.println("数据总条数:"+page.getTotals());
        return userList;
    } 
    --------------------------------
    关小羽
    小露娜
    亚麻瑟
    小鲁班
    数据总条数:4
    

    三、总结

    本章节重点阐述了Mybatis中插件的实际使用过程。在日常开发中,缓存和分页基本上都是可以常见的功能点。你完全可以高度自定义自己的缓存机制,缓存的时机、缓存Key的设计、过期键的设置等....对于分页你也应该更加清楚它们的实现逻辑,以便未来在选型的时候,你会多一份选择。

    相关文章

      网友评论

        本文标题:Mybatis源码分析(七)自定义缓存、分页的实现

        本文链接:https://www.haomeiwen.com/subject/kzlazftx.html