Mybatis源码深度解析之${}字符串替换

5,542 阅读2分钟

mybatis的${}字符串替换是经常被用到的一个特性,虽然使用${}进行替换容易被sql注入,但只要确保替换的内容不是来自于外部或者对内容进行强校验还是可以避免其带来的风险的。这里我们来看一下mybatis是如何实现${}的字符串替换的。

先简单回顾一下${}的使用:

<mapper namespace="cn.xxx.test.mybatis.mapper.UserMapper">
    <select id="queryUserById" resultType="Map">
       select * from user
       <where>
           <if test="id != null">
               id = ${id}
           </if>
       </where>
        limit 1
    </select>
</mapper>

UserMapper中配置了一个简单的查询,将传入的id参数直接替换到查询条件中,在调用查询时指定id的值:

List<?> datas = sqlSession.selectList("queryUserById", 1);

二、DynamicContext

mybatis使用Ognl来解析${}中的表达式,所以这里简单回顾一下Ognl的使用。

2.1 Ognl简单介绍

Ognl-对象导航图语言(Object Graph Navigation Language),其定制了一套表达式来访问和操作对象。使用Ognl时需要一个Map类型的上下文环境,在解析Ognl表达式时从上下环境中获取值,上下文中包含一个root对象,当无法从上下文中获取到指定的属性时就从root中获取。

public class OgnlTest {

    static class User {
        private String id;
        private String name;
        private String age;

        public User(String id, String name, String age) {
            this.id = id;
            this.name = name;
            this.age = age;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }
    }


    public static void main(String[] args) throws Exception {
        User user = new User("1", "zhangsan", "23");
        // 创建一个上下文,root为user
        OgnlContext context = (OgnlContext) Ognl.createDefaultContext(user, new DefaultMemberAccess(true));
        // 上下文中添加userCount属性
        context.put("userCount", 11);
        // 获取userCount,上下文中的属性使用#访问
        Object ans = Ognl.getValue(Ognl.parseExpression("#userCount"), context, context.getRoot());
        System.out.println("userCount = " + ans);
        // 获取root中的age,root中的属性不使用#
        ans = Ognl.getValue(Ognl.parseExpression("age"), context, context.getRoot());
        System.out.println("age = " + ans);
        // 指定值,会调用setName方法
        ans = Ognl.getValue(Ognl.parseExpression("name=\"lisi\""), context, context.getRoot());
        System.out.println("name = " + user.getName());
    }
}

执行输出:

userCount = 11
age = 23
name = lisi

2.2 ContextMap

我们使用${}时其中表达式并不包含#号,所以mybatis在解析${}时所有的属性都从root中获取,我们先来mybatis解析时使用的root对象的内容和实现。

mybatis在使用Ognl时使用的root对象为ContextMap它是org.apache.ibatis.scripting.xmltags.DynamicContext中的一个内部类。ContextMap是HashMap的子类, 即默认情况下${}中的表达式的属性都是从ContextMap中获取,所以我们有必要看一下ContextMap包含了那些内容以及属性是如何返回的。

在ContextMap中包含了两个属性parameterMetaObject和fallbackParameterObject,起定义如下:

static class ContextMap extends HashMap<String, Object> {
    // 传入的参数的MetaObject
    private final MetaObject parameterMetaObject;
    // 传入的参数类型是否有TypeHandler
    private final boolean fallbackParameterObject;

    public ContextMap(MetaObject parameterMetaObject, boolean fallbackParameterObject) {
      this.parameterMetaObject = parameterMetaObject;
      this.fallbackParameterObject = fallbackParameterObject;
    }
}

MetaObject是一个对象实例的元数据,其中包含了对象中属性、属性set/get方法和实例,通过它可以判断实例是否存在某个属性的get/set方法并可以发起调用。执行语句时若我们外面传入的参数非空并且非Map的,就会解析得到我们传入参数的MetaObject给到parameterMetaObject,后面ognl在获取属性时就会调用MetaObject提供的方法去调用对应属性的get方法。

fallbackParameterObject则是我们传入的参数的类型是否存在TypeHandler。

既然ContextMap是map类型的,那么获取属性自然是调用get方法去获取属性,为了支持非Map类型的参数,ContextMap覆盖get方法,存在以下几种情况:

  1. 若ContextMap存在指定的属性则直接放回,但只用bind额外添加的元素才会在Map中存在。
  2. 查询时我们传入的参数是一个对象,那么也就会生产参数parameterMetaObject,这时就使用MetaObject的getValue方法,该方法会查找该属性在参数中的get方法并调用获取到值。
  3. 查询时我们传入的参数是一个基础类型参数,那么这时parameterMetaObject中自然不存在get方法,这时就直接返回参数值。
static class ContextMap extends HashMap<String, Object> {
    @Override
    public Object get(Object key) {
      // 直接从属性中获取
      String strKey = (String) key;
      if (super.containsKey(strKey)) {
        return super.get(strKey);
      }
	  // parameterMetaObject则返回null
      if (parameterMetaObject == null) {
        return null;
      }
      // 配置了TypeHanler并且不存该属性的get方法,则返回参数值
      if (fallbackParameterObject && !parameterMetaObject.hasGetter(strKey)) {
        return parameterMetaObject.getOriginalObject();
      } else {
        // 从参数对应属性的get方法中获取
        return parameterMetaObject.getValue(strKey);
      }
    }
}

以上ContextMap实现明显确缺少了一种情况,即我们给定的参数是Map类型,在参数是Map时是不会创建parameterMetaObject的,自然也就无法获取到属性值。
这个问题通过PropertyAccessor解决,PropertyAccessor是Ognl中定义的一个接口,用于指定获取属性和设置属性时的具体行为。mybatis中定义了一个对ContextMap的PropertyAccessor的实现ContextAccessor。从ContextMap中获取属性时会调用ContextAccessor的getProperty方法,在getProperty方法中会先从ContextMap中获取,当从ContextMap的get中获取不到时会判断从指定参数是否为Map,若为Map则调用其get方法。

static class ContextAccessor implements PropertyAccessor {
    @Override
    public Object getProperty(Map context, Object target, Object name) {
      Map map = (Map) target;
      Object result = map.get(name);
      if (map.containsKey(name) || result != null) {
        return result;
      }
	  // PARAMETER_OBJECT_KEY值为指定的参数
      Object parameterObject = map.get(PARAMETER_OBJECT_KEY);
      if (parameterObject instanceof Map) {
        return ((Map)parameterObject).get(name);
      }

      return null;
    }
}

sqlSession每次调用都会为当前调用生成一个ContextMap供这一次调用使用。

二、如何进行替换

上一篇文章中介绍了xml是如何转化为可执行sql的,这里再做一个简单回顾。

在解析xml配置阶段,mybatis会将每个使用xml配置的语句解析为一个MappedStatement,MappedStatement中封装了语句的个各个属性,如超时、resultType、fetchSize等,除了这些属性外还有一个SqlSource属性,SqlSource是对语句的xml的封装,在可执行的sql就由SqlSource来提供。

在解析xml是会将xml的各个节点都解析为其对应的SqlNode类型,并按配置组织为一个树结构,然后将root节点保存到SqlSource中。当使用SqlSession执行语句时会从SqlSource中获取sql,SqlSource在生成sql时会根据条件以深度优先的策略遍历树中的各个节点并调用节点的apply方法,让节点将自己的语句添加到最终的sql上。

例如上面的queryUserById语句就会被解析为如下结构:

在xml中对${}的引用都是写在文本节点中的,所以对应${}的替换由TextSqlNode进行,也就是TextSqlNode的applay方法在生成自己这部分的sql是会进行${}的替换。

在TextSqlNode的applay方法中会获取到文本中所有的${}引用,并逐个进行替换。在替换时使用Ognl解析${}中的表达式从ContextMap中获取指定的属性的值,若获取到的值不为null,则使用String.valueOf获取到值的字符串并进行替换,若为null则使用空字符串进行替换。

public class TextSqlNode implements SqlNode {

	private static class BindingTokenParser implements TokenHandler {
        @Override
        public String handleToken(String content) {
          // context即包含我们指定的参数的ContextMap
          Object parameter = context.getBindings().get("_parameter");
          if (parameter == null) {
            context.getBindings().put("value", null);
          } else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
            // 简单类型则额外添加“value”到上下文中,值为我们给定的参数
            context.getBindings().put("value", parameter);
          }
          // ognl解析${}中的内容并获取到值
      	  Object value = OgnlCache.getValue(content, context.getBindings());
          // 值的字符串
          String srtValue = value == null ? "" : String.valueOf(value); 
         // 应用插件,这个插件还未开放默认为null
         checkInjection(srtValue);
         return srtValue;
       }
    }
}

可以看到TextSqlNode在解析时若我们给定的参数为类型为简单类型的话,TextSqlNode还会建将我们的参数添加到上下文中,key为‘value’。

简单类型有下面这些:

  static {
    SIMPLE_TYPE_SET.add(String.class);
    SIMPLE_TYPE_SET.add(Byte.class);
    SIMPLE_TYPE_SET.add(Short.class);
    SIMPLE_TYPE_SET.add(Character.class);
    SIMPLE_TYPE_SET.add(Integer.class);
    SIMPLE_TYPE_SET.add(Long.class);
    SIMPLE_TYPE_SET.add(Float.class);
    SIMPLE_TYPE_SET.add(Double.class);
    SIMPLE_TYPE_SET.add(Boolean.class);
    SIMPLE_TYPE_SET.add(Date.class);
    SIMPLE_TYPE_SET.add(Class.class);
    SIMPLE_TYPE_SET.add(BigInteger.class);
    SIMPLE_TYPE_SET.add(BigDecimal.class);
  }