MyBatis spring howto

Table of Contents

MyBatis本质上还是一个ORM框架,ORM框架的核心还是把对对象的操作,映射到对数据库的操作(感觉设计中是反过来的,把对数据库的操作封装成对对象的操作)。映射涉及到一系列选择:

  1. 数据库如何表示
  2. 对象的方法如何映射到SQL,成员如何映射到表的字段
  3. Java数据类型如何映射到SQL的数据类型

最核心的是SqlSessionFactory,在这个类里面完成了数据库的配置,注册了需要映射的对象(在MyBatis中其实是一个接口,称其为Mapper)。Mapper定义了SQL和函数的关系。

MyBatis-Spring 是MyBatis在Spring下的封装,主要为了使用Spring的依赖注入。因为依赖注入本身依赖于约定和选择,当依赖注入的选择比较复杂时,需要手动介入,特别涉及到多库的时候。

1 Mapper 的定义

Mapper是开发者关注最多的部分,先看看它是如何定义的。

public interface EmployeeMapper {
  class Sql {
    final static String TABLE = "employee";
    final static String SELECT = "SELECT * FROM " + TABLE + " WHERE id = #{id}";
    final static String SELECT_ALL = "SELECT * FROM " + TABLE + " ORDER BY id DESC LIMIT #{limit}";
    final static String SELECT_PAGE = "SELECT * FROM " + TABLE +
      " WHERE id < #{id} ORDER BY id LIMIT #{limit}";
    final static String DELETE = "DELETE FROM " + TABLE + " WHERE id = #{id}";

    public static String selectByName(Map<String, Object> param) {
      SQL sql = new SQL().SELECT("*").FROM(TABLE)
        .WHERE("name = #{name}");

      Employee.Gender gender = (Employee.Gender) param.get("gender");
      if (gender != null) {
        sql.AND().WHERE("gender = #{gender}");
      }

      return sql.toString();
    }

    public static String insert(Employee employee) {
      SQL sql = new SQL()
        .INSERT_INTO(TABLE)
        .VALUES("name", "#{name}")
        .VALUES("phone", "#{phone}");
      if (employee.getDepartment() != null) {
        sql.VALUES("department", "#{department}");
      }
      if (employee.getPosition() != null) {
        sql.VALUES("position", "#{position}");
      }
      if (employee.getGender() != null) {
        sql.VALUES("gender", "#{gender}");
      }
      return sql.toString();
    }

    public static String update(Employee employee) {
      SQL sql = new SQL().UPDATE(TABLE);
      if (employee.getName() != null) {
        sql.SET("name = #{name}");
      }
      if (employee.getDepartment() != null) {
        sql.SET("department = #{department}");
      }
      if (employee.getPosition() != null) {
        sql.SET("position = #{position}");
      }
      if (employee.getGender() != null) {
        sql.SET("gender = #{gender}");
      }
      if (employee.getPhone() != Integer.MIN_VALUE) {
        sql.SET("phone = #{phone}");
      }
      return sql.WHERE("id = #{id}").toString();
    }
  }

  @Select(Sql.SELECT_ALL)
  List<Employee> findAll(long limit);

  @Select(Sql.SELECT_PAGE)
  List<Employee> findPager(@Param("id") long id, @Param("limit") long limit);

  @SelectProvider(type = Sql.class, method = "selectByName")
  List<Map<String, Object>> findByName(@Param("name") String name,
                                       @Param("gender") Employee.Gender gender);

  @Select(Sql.SELECT)
  Employee find(@Param("id") long id);

  @InsertProvider(type = Sql.class, method = "insert")
  @Options(useGeneratedKeys=true, keyProperty = "id")
  int add(Employee employee);

  @UpdateProvider(type = Sql.class, method = "update")
  int update(Employee employee);

  @Delete(Sql.DELETE)
  int delete(long id);
}

首先 EmployeeMapper 是个接口,其次它的每一个方法都和一个SQL,或者一个生成SQL的方法关联。这里用了嵌套类EmployeeMapper.Sql只是为了分离SQL和SQL对应的方法。

1.1 从方法到SQL

这里通过注解将方法和其对应的SQL关联起来,注解很多,这里列出常用的。

注意 Mapper中方法不能重载

注意 MySQL类型 BOOLEAN 实际是 tinyint(1) ,对应于Java的类型也是 Boolean

1.1.1 通过 Select Insert Update Delete 注解绑定静态SQL

findAll delete 所示,这里要求SQL都是编译时常量。静态SQL简单,但是缺乏一定的灵活性。

1.1.2 通过 SelectProvider InsertProvider UpdateProvider DeleteProvider 注解绑定动态SQL

insert update 所示,通过type指定产生SQL的类,通过method指定类的方法。

1.1.3 通过 Options 注解可以指定额外的选项

这里useGeneratedKeys 指出需要获取自增ID, keyProperty 指定自增ID对应的字段名

1.2 从方法的参数到SQL的参数

Mapper中SQL的参数如何表示呢? 使用这样的形式 #{name} ,表示此处是一个参数,它的名字是 name 。显然我们需要把传递给方法的参数,和SQL中的参数绑定起来。当查询返回时,还需要把查询结果和方法的返回值绑定。绑定关系一旦确定,还需要 JavaTypeJava.sql.Types 的类型转换,使用所谓的 TypeHandler ,这个后面单独说。

注意 方法的参数不能为null,如果可能为null,需要用动态SQL判断一下。

1.2.1 单个简单参数

参数类型是long,String,并且只有一个。此时SQL需要绑定变量时,只能绑定它。

1.2.2 多个简单参数

此时需要通过 Param 注解来标记变量对应的SQL变量的名字。例如 findByName(@Param("name") String name, @Param("gender") Employee.Gender gender) 指定了 name 绑定到 #{name} gender 绑定到 #{gender}

注意 动态生成的SQL的函数,只接受一个参数。EmployeeMapper.Sql 中的所有函数都只有一个参数,这里有个小转换。例如:EmployeeMapper.findByName 有两个参数,但是它的动态SQL函数 EmployeeMapper.Sql.selectByName 却只接受一个函数,此时MyBatis会把 EmployeeMapper.findByName 的两个参数转成一个Map,Map的key是Param注解的值。

1.2.3 对象参数

如果SQL需要 #{name} 对应的值时,会调用对象的 getName 方法。如果有 publicname 字段也可以。

1.2.4 Map参数

如果SQL需要 #{name} 时,调用Map的 get(Object key) 方法。

1.2.5 查询返回值

如果查询返回一行,那么返回值可以是一个Map或者一个对象。返回的数据行绑定到Map,行的列对应Map的Key,数据类型是String,列值对应Map的Value,数据类型由默认的TypeHandler决定。返回的数据绑定到对象,和对象参数的绑定方法一样。

如何返回多行,返回值是一个Map或者对象的List。

如果增删改SQL,那么返回值是int,返回影响的行数。

注意 返回Map和返回对象有一点不同。返回对象时,知道 JavaTypejava.sql.Types ,所以TypeHandler的作用更大,但是返回Map时,没有预定义的 JavaType 。例如:对象写入数据库时,假设把enum转成了int,那么从数据库读数据时,如果返回Map,读到int,并不知道需要转成enum。

1.3 区分三类数据值

  • null 值, null 是没有值的意思,值没有设置。基本类型int,long没有null,推荐使用MIN_VALUE当null。
  • 默认值,当值为 null 时采用的值,可以没有。
  • 设置的值。

区分这三个值的意义在于,如果是读操作,如果有默认值的话,那么可以给 null 值一个默认值。如果是写操作,那么 null 值意味着不要更改数据库的值。

1.4 动态SQL

动态SQL的构建函数(method)只是构建SQL,值绑定并不是发生在这个阶段。但是在这个阶段显式绑定也没有太大问题。例如 sql.SET("name = #{name}") 写成 sql.SET("name = " + employee.getName()) 也没有什么不可以,除了可能引发SQL注入。

1.5 SQL注入

客户端请求SQL服务器执行SQL有两种方式。一种是请求最终的SQL,例如 SELECT * FROM employee WHERE id = 10 。一种是先请求 SELECT * FROM employee WHERE id = #{id} ,然后发送 id 对应的具体值过去,这被称作 Prepare Execute 两个阶段。

前者更直观,也是SQL注入的地方。因为10是用户输入的,如果缺少过滤,用户可能输入 10; drop table employee ,这样最终的SQL语句是 SELECT * FROM employee WHERE id = 10; drop table employeedrop 语句被注入了。

后者没有这个问题,后者执行 id 为 10; drop table employee 的查询。虽然可以防止SQL注入,但是并不能预防持久型XSS攻击,适当的输入过滤有时还是必要的。

2 SqlSessionFactory

Mapper定义了函数到SQL的映射,可是怎么用呢?首先Mapper是个 接口 ,其次它没有关联任何的数据库。SqlSessionFactory完成了Mapper的 实现 ,并且关联了数据库。

SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource);

SqlSessionFactory sqlSessionFactory = (SqlSessionFactory) sqlSessionFactoryBean.getObject();
sqlSessionFactory.getConfiguration().addMapper(EmployeeMapper.class);
sqlSessionFactory.getConfiguration().getTypeHandlerRegistry().register(
  Employee.Gender.class, new EnumValueTypeHandler<Employee.Gender>(Employee.Gender.class));

SqlSessionTemplate sqlSession = new SqlSessionTemplate(sqlSessionFactory);
EmployeeMapper employeeMapper = sqlSession.getMapper(EmployeeMapper.class);
Employee employee = employeeMapper.find(id);

上面代码分两部分,一是SqlSessionFactory的构造,一是得到Mapper接口的实例。

2.1 构造 SqlSessionFactory

构造SqlSessionFactory主要分两部分,一个是配置DataBase,一个是配置要注册的Mapper。配置DataBase是个很常规的操作,自动生成Mapper实例就有点神奇了。

MyBatis利用 CGLIB 自动生成Mapper的实现,这并不是特别难的事情,因为操作数据的代码,除了SQL和对象绑定,代码基本是相同的,而SQL和对象绑定已经在Mapper中说明了。

2.2 和Spring的融合

事情到这里按理该结束了,但是这个不符合spring的风格,EmployeeMapper不是应该自动注入的吗?是的,在Spring中使用MyBatis非常简单。只需要配置 SqlSessionFactory,和需要注册的Mapper就可以了。

Spring 自动绑定

@Autowired EmployeeMapper employeeMapper;

配置SqlSessionFactory

@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
  SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
  sqlSessionFactoryBean.setDataSource(dataSource);

  SqlSessionFactory sqlSessionFactory = (SqlSessionFactory) sqlSessionFactoryBean.getObject();
  sqlSessionFactory.getConfiguration().getTypeHandlerRegistry().register(
    Employee.Gender.class, new EnumValueTypeHandler<Employee.Gender>(Employee.Gender.class));
  return sqlSessionFactory;
}

这里没有注册Mapper,因为Mapper可能很多,如果一个个手工注册,维护起来比较麻烦,有两个方法可以用于自动扫描需要注册的Mapper。

2.2.1 使用 MapperScan 发现Mapper

@Configuration
@MapperScan("example.mapper")

所有在package example.mapper下Mapper都会被自动注册到sqlSessionFactory。

2.2.2 使用 MapperScannerConfigurer

@Bean
public MapperScannerConfigurer mapperScannerConfigurer() {
  MapperScannerConfigurer configurer = new MapperScannerConfigurer();
  configurer.setBasePackage("example.mapper");
  configurer.setSqlSessionFactoryBeanName(sqlSessionFactory());
  return configurer;
}

这里配置了SqlSessionFactory和需要扫描的Mapper的BasePackage。

自此,和Spring的融合就完成了。借助Spring的机制,很多操作自动化了,例如,生成EmployeeMapper的Bean。具体可以研究 MapperScannerConfigurer.java 看这个过程是怎样的。

前面说过,注入依赖于约定,那么假设不止一个数据库呢?多个数据库用同一个Mapper呢?听起来还挺复杂的。

3 应对多个数据库 (1)

一个应用同时使用多个数据库是很常见的事情,同时,为了应对不断增长的数据,分库分表是常见的策略。分表很简单,利用动态SQL,动态生成表名就可以了,毕竟还是在同一个数据库下面。分库怎么办?

通常说的分库,是把同一张表按照某个KEY分到多个数据库中,首先是多个库,其次是表的schema一样,也即Mapper一样。而单纯的多库,表的schema是不同的。

有必要先提一句,如果不是为了利用Spring的自动绑定,每次手动获取Mapper,就像 上面 所做的那样,使用MyBatis毫无压力,所以当我们面临复杂的架构时,总是可以手动实现Bean,然后自动绑定。

3.1 多个库,Mapper不相同

这个其实并不难,在 使用 MapperScannerConfigurer 部分事实上已经演示过了,定义多个 SqlSessionFactory ,每个对应不同的DataSource,再定义多个 MapScannerConfigurer ,每个使用不同的 SqlSessionFactory 和 不同的BasePackage扫描不同的Mapper。

3.2 多个库,Mapper相同

似乎和上个问题一样,但是Mapper影响巨大,考虑到Spring自动绑定的重要依据是数据类型,Mapper相同意味着数据类型是相同的。这里的需求不是自动绑定一个Mapper,而是自动绑定Mapper的一个数组,每个Mapper对应一个数据库。

生成多个SqlSessionFactory并无难事,我们手动实现一个Bean ArrayList<EmployeeMapper>

@Bean(name = "dbHorizontalPartition")
public ArrayList<EmployeeMapper> dbHorizontalPartition() throws Exception {
  ArrayList<SqlSessionFactory> sqlSessionFactoryList = dbHorizontalPartitionSqlSessionFactory();
  ArrayList<EmployeeMapper> employeeMapperList = new ArrayList<>();

  for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
    SqlSessionTemplate sqlSessionTemplate = new SqlSessionTemplate(sqlSessionFactory);
    employeeMapperList.add(sqlSessionTemplate.getMapper(EmployeeMapper.class));
  }

  return employeeMapperList;
}

自动绑定,根据分区函数,选择合适的EmployeeMapper,也即选择合适的数据库。

@Autowired @Qualifier("dbHorizontalPartition")
ArrayList<EmployeeMapper> employeeMapperList;

4 应对多个数据库 (2)

(1) 中,核心是一个Mapper一个DataSource,如果需要一个Mapper多个DataSource,就手动生成Mapper的多个实例和DataSource对应,一方面用起来不是很方便,另一方面暴露了逻辑上只有一个DataSource的细节。

另一种思路是,使用一个 逻辑上 的DataSource封装(并代理)所有 物理上 的DataSource,然后根据某些准则选择特定的DataSource,从而实现一个Mapper动态的使用不同的DataSource。可以继承Spring的 AbstractRoutingDataSource ,(所有 物理上 DataSource都有一个名字)并实现 determineCurrentLookupKey() 返回本次所需的DataSource的名字。

可以采用包含了 ThreadLocal 成员的单例(其实就是个每线程全局变量),假设名为 CustomerContextHolder,在调用Mapper前设置需要的DataSource的一些信息,在 determineCurrentLookupKey 中获取这些信息,并根据这些信息选择合适的DataSource的名字。参考 Dynamic DataSource Routing 。作为一种简化,可以使用AOP,在Mapper函数调用前,通过获取函数的注解,或者获取函数参数的注解来设置DataSource。如下是两个用例:

4.1 自动主从分离

在Mapper函数调用前,通过获取 注解,例如@Select,来判断当前操作是 还是 ,设置CustomerContextHolder的值为 slavemaster ,如果有多个slave,可以在 determineCurrentLookupKey 中随机选择一个。

4.2 自动分多个库

在Mapper编写过程中,通过引入 @PartitionKey 注解,例如 find(@PartitionKey long id) 或者 add(@PartitionKey("id") Entity entity),在Mapper函数调用前,获取id的值,并设置到 CustomerContextHolder 中,determineCurrentLookupKey 则根据id的值返回相应分区的DataSource的名字。

4.3 总结

当一个Mapper对应多个DataSource的情况下,用这种方法更简单。可以隐藏分区的细节。当不同Mapper对应不同DataSource的情况下,配置多个 MapperScannerConfigurer 似乎更简单。这部分的实例代码,可以从 SmartDataSource 中找到。

5 TypeHandler

MyBatis 自身定义了很多的 TypeHandlers ,它的本质还是定义JavaType 如何转成 SqlType 。这个并没有什么难的,可以参考 spring-howto 中的样例代码, LocalDateTimeTypeHandlerEnumValueTypeHandler

这里特别说一下Enum类型,默认是 EnumTypeHandler ,转成enum转成字符串存入数据库。为了优化考虑,可能会想要把enum转成数字存入数据库,这时会选择 EnumOrdinalTypeHandler ,但这是一个陷阱。因为Enum自身的原因,在增删了Enum元素之后,对应的ordinal是会变的。可以考虑样例代码中的 EnumValueTypeHandler ,它可以给每个Enum元素绑定一个数字值。

6 缓存

Mybatis 可以配置二级缓存,但是我认为基本是鸡肋。缓存主要面临两个问题,而这两个问题,Mybatis都没有很好的解决。

  • 缓存的KEY是什么,也即如何选择缓存粒度?
  • 何时清理缓存,也即如何保持数据一致性?

一条查询SQL执行成功,如果需要缓存结果,那么选择什么当KEY呢?直观的选择是拿SQL语句当KEY,可是数据变更时,如何清理缓存呢?变更数据的SQL显然不知道有哪些相关的查询SQL需要被清理,所以只能清理掉所有缓存(这正是MySQL自身的查询缓存策略,而在实践中是推荐关掉MySQL的查询缓存的),或者不清理任何缓存,让数据过期,容忍短暂的数据不一致。

Mybatis 正是用查询SQL当缓存的KEY,当有变更数据的SQL发生时,清理掉所有缓存。在高并发的情况下,这种缓存策略对性能提升有限,而并发不高时,用缓存根本没有意义。

有缓存需求时,可以考虑在DAO之上封装一层,可以采用数据库行的PRIMARY KEY作为缓存,粒度较细。并且更新总是可以根据PRIMARY KEY更新。缓存总是带来复杂度的提升,优先考虑优化查询和数据库,缓存带来的好处非常显然时,再考虑使用缓存,并且要谨慎选择缓存的KEY。

Author: zzyong wangwenlong

Created: 2017-12-12 二 11:02

Validate