MyBatis spring howto
Table of Contents
MyBatis本质上还是一个ORM框架,ORM框架的核心还是把对对象的操作,映射到对数据库的操作(感觉设计中是反过来的,把对数据库的操作封装成对对象的操作)。映射涉及到一系列选择:
- 数据库如何表示
- 对象的方法如何映射到SQL,成员如何映射到表的字段
- 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中的参数绑定起来。当查询返回时,还需要把查询结果和方法的返回值绑定。绑定关系一旦确定,还需要 JavaType
和 Java.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 方法。如果有 public 的 name 字段也可以。
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和返回对象有一点不同。返回对象时,知道 JavaType
和 java.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 employee
, drop 语句被注入了。
后者没有这个问题,后者执行 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的值为 slave 或 master ,如果有多个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 中的样例代码, LocalDateTimeTypeHandler
和 EnumValueTypeHandler
。
这里特别说一下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。