SpringBoot集成Mybatis
背景
Mybatis是一个半自动化的ORM(对象关系映射)框架,它通过XML或注解的方式将Java对象和SQL语句进行映射,从而实现对数据库的增删改查操作。
Mybatis ORM简介
基本概念:Mybatis简化了数据访问层的开发,通过映射配置文件或注解将Java对象与数据库表进行映射,实现了对象关系映射。
优点:Mybatis提供了灵活的SQL编写方式,支持动态SQL,易于与Spring等框架集成,同时减少了代码量,提高了开发效率。
缺点:相比于全自动化的ORM框架如Hibernate,Mybatis需要编写更多的SQL语句,对开发人员的SQL编写能力有一定要求
Mybatis ORM的使用过程
创建SqlSessionFactory:可以通过配置文件或直接编码来创建SqlSessionFactory。
使用SqlSession进行数据库操作:通过SqlSession执行SQL语句,获取结果,并映射到Java对象。
引入依赖
<!-- 整合mybatis相关依赖 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- 数据库驱动(例如 MySQL) -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
数据库配置
数据库表初始化
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(255) NOT NULL DEFAULT '' COMMENT '账号',
`age` int(2) NOT NULL DEFAULT 0 COMMENT '年龄',
`email` varchar(255) NOT NULL DEFAULT '' COMMENT '邮箱',
`tenant_id` bigint(20) NOT NULL DEFAULT 1 COMMENT '租户id,1表示默认租户',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
xml文件映射sql:UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bryant.mapper.UserMapper">
<resultMap id="BaseResultMap" type="com.bryant.model.UserDetail">
<id column="id" property="id" jdbcType="INTEGER"/>
<result column="name" property="name" jdbcType="VARCHAR"></result>
<result column="age" property="age" jdbcType="INTEGER"></result>
<result column="email" property="email" jdbcType="VARCHAR"></result>
<result column="tenant_id" property="tenantId" jdbcType="BIGINT"></result>
</resultMap>
<sql id="base_columns">
`id`, `name`, age, email, tenant_id
</sql>
<insert id="insert" parameterType="map" keyColumn="id">
insert
into user
(id, name, age, email, tenant_id)
values
(#{record.id,jdbcType=INTEGER},
#{record.name,jdbcType=VARCHAR},
#{record.age,jdbcType=INTEGER},
#{record.email,jdbcType=VARCHAR},
#{record.tenantId,jdbcType=BIGINT}
)
</insert>
<update id="updateById" parameterType="map">
update user
set `age` = #{record.age},
`name` = #{record.name},
`email` = #{record.email}
where id = #{record.id}
</update>
<delete id="deleteById">
delete from user
where id = #{id}
</delete>
<select id="getById" resultMap="BaseResultMap">
select
<include refid="base_columns"/>
from user
where id = #{id}
and tenant_id = #{tenantId}
</select>
</mapper>
应用配置
启动类
//...其他注解
// mapper扫描的包路径
@MapperScan("com.bryant.mapper")
@SpringBootApplication
public class UserServer {
public static void main(String[] args) {
SpringApplication.run(UserServer.class, args);
}
}
注意标黄的代码段@MapperScan,是对包路径的扫描。
Mybatis配置
/**
* Full(proxyBeanMethods = true) :proxyBeanMethods参数设置为true时即为:Full 全模式。该模式下注入容器中的同一个组件无论被取出多少次都是同一个bean实例,即单实例对象,在该模式下SpringBoot每次启动都会判断检查容器中是否存在该组件
* Lite(proxyBeanMethods = false) :proxyBeanMethods参数设置为false时即为:Lite 轻量级模式。该模式下注入容器中的同一个组件无论被取出多少次都是不同的bean实例,即多实例对象,在该模式下SpringBoot每次启动会跳过检查容器中是否存在该组件
*/
@Configuration(proxyBeanMethods = true)
@ConditionalOnProperty(prefix = "users.mybatis.custom", name = "interceptor", havingValue = "true")
@Slf4j
public class MybatisConfig implements InitializingBean {
@Autowired
private List<SqlSessionFactory> sqlSessionFactorys;
@Bean
@Order(1)
public TenantIdInjectInterceptor mybatisInterceptor() {
log.info("TenantIdInjectInterceptor interceptor init...");
return new TenantIdInjectInterceptor();
}
/**
* @Order(0) 是为了保证 TenantIdInjectInterceptor拦截器先于sqlMonitorInterceptor拦截器执行
* @return
*/
@Bean
@Order(0)
public SqlMonitorInterceptor sqlMonitorInterceptor() {
log.info("SqlMonitorInterceptor interceptor init...");
return new SqlMonitorInterceptor();
}
@Override
public void afterPropertiesSet() throws Exception {
}
}
Mybatis插件配置
SQL租户ID检测插件-TenantIdInjectInterceptor
可以参考上2篇文章:Mybatis插件-租户ID的注入&拦截应用、Mybatis链路分析:JDK动态代理和责任链模式的应用
/**
* 拦截器做了2个事情
* - 1.打印原sql
* - 2.注入自定义参数
*
* Mybatis 插件的执行顺序有两种:
* 1、不同拦截对象执行顺序,如下:
* Executor` -> `StatementHandler` -> `ParameterHandler` -> `ResultSetHandler`
* 2、拦截相同对象执行顺序,取决于 mybatis-config.xml 中 <plugin> 配置顺序,越靠后,优先级越高。
* 3、拦截相同对象执行顺序,如果是使用的配置类加载,则取决于配置类的加载顺序,加载顺序,越靠后,优先级越高;
*/
@Slf4j
@Intercepts(value = {
@Signature(type = Executor.class, method = "update", args ={MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args =
{MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args =
{MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
public class TenantIdInjectInterceptor implements Interceptor {
//用于匹配字符串中的tenant_id关键字,后面可以跟任意数量的空格,然后是等号=,再后面可以跟任意数量的空格,最后是一个问号?。
// 这个正则表达式使用了Pattern.CASE_INSENSITIVE标志,表示匹配时不区分大小写。
// 匹配问号,因为问号在正则表达式中有特殊含义(表示前面的字符出现0次或1次),所以需要用两个反斜杠进行转义
private static final Pattern p = Pattern.compile("tenant_id(\\s+)?=(\\s+)?\\?", Pattern.CASE_INSENSITIVE);
private static final String SQL_IGNORED_CHARACTOR = "[\\t\\n\\r]";
/**
* 核心逻辑在intercept方法,内部实现 sql 获取,参数解析,耗时统计
* @param invocation
* @return
* @throws Throwable
*/
@Override
public Object intercept(Invocation invocation) throws Throwable {
log.info("TenantIdInjectInterceptor interceptor start...");
Object[] args = invocation.getArgs();
MappedStatement mappedStatement = (MappedStatement) args[0];
String namespace = mappedStatement.getId();
String className = namespace.substring(0, namespace.lastIndexOf("."));
String methodName = mappedStatement.getId()
.substring(mappedStatement.getId().lastIndexOf(".") + 1);
Class<?> c = Class.forName(className);
//如果Class有注解指明要忽略本次拦截,则放弃拦截
if (c.isAnnotationPresent(TenantIdInjectConfig.class) && c.getAnnotation(TenantIdInjectConfig.class).ignore()) {
return invocation.proceed();
}
Method[] ms = c.getMethods();
Method method = Arrays.stream(ms).filter(m -> m.getName().equals(methodName)).findAny().get();
//如果method 有注解指明要忽略本次拦截,则放弃拦截
if (method.isAnnotationPresent(TenantIdInjectConfig.class) && method.getAnnotation(TenantIdInjectConfig.class)
.ignore()) {
return invocation.proceed();
}
//判断SQL中是否存在tenant_id 字段,如果存在,认为已经考虑多租户的情况,否则将SQL拦截下来
BoundSql boundSql = mappedStatement.getSqlSource().getBoundSql(args[1]);
String sql = boundSql.getSql().toLowerCase(Locale.CHINA).replaceAll(SQL_IGNORED_CHARACTOR, " ");
boolean sqlWithTenantIdParam;
if (mappedStatement.getSqlCommandType().equals(SqlCommandType.INSERT)) {
//insert语句只判断是否有tenant_id
sqlWithTenantIdParam = sql.contains("tenant_id");
} else {
//其他语句判断是否有tenant_id=?
sqlWithTenantIdParam = p.matcher(sql).find();
}
if (!sqlWithTenantIdParam) {
log.error("缺少对多租户的支持,tenant_id 字段缺失,sql:{}", sql);
throw new RuntimeException("缺少对多租户的支持,tenant_id字段缺失");
}
//这里使用默认的租户id=1
String defaultTenantId = "1";
Map map;
if (args[1] == null) {
//没有参数列表
map = new MapperMethod.ParamMap<>();
} else if (!(args[1] instanceof MapperMethod.ParamMap)) {
//单参数
Map tempMap = new MapperMethod.ParamMap<>();
Parameter[] parameters = method.getParameters();
Parameter param = parameters[0];
//第一个参数获取@Param注解,然后获取值
if (param.isAnnotationPresent(Param.class)) {
String paramName = param.getAnnotation(Param.class).value();
tempMap.put(paramName, args[1]);
} else if (checkTypeType(param)) {
//如果是基础类型抛出异常
tempMap.put(param.getName(), args[1]);
} else {
//如果没有指定@Param,将单参数的属性单独拎出来处理
Object arg = args[1];
Field[] fields = arg.getClass().getDeclaredFields();
for (Field field : fields) {
if (!Modifier.isStatic(field.getModifiers())) {
if (!field.isAccessible()) {
field.setAccessible(true);
}
tempMap.put(field.getName(), field.get(arg));
}
}
}
args[1] = tempMap;
}
//如果是多参数列表时直接转map即可
map = (Map) args[1];
if (!StringUtils.isBlank(defaultTenantId)) {
map.put("tenantId", Long.parseLong(defaultTenantId));
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
private boolean checkTypeType(Object object) {
if (object.getClass().isPrimitive()
|| object instanceof String
|| object instanceof Boolean
|| object instanceof Double
|| object instanceof Float
|| object instanceof Long
|| object instanceof Integer
|| object instanceof Byte
|| object instanceof Short) {
return true;
} else {
return false;
}
}
}
SQL监控插件-SqlMonitorInterceptor
@Slf4j
@Intercepts(value = {
@Signature(type = Executor.class, method = "update", args ={MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args =
{MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args =
{MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
public class SqlMonitorInterceptor implements Interceptor {
private static final String SQL_IGNORED_CHARACTOR = "[\\t\\n\\r]";
@Override
public Object intercept(Invocation invocation) throws Throwable {
log.info("SqlMonitorInterceptor interceptor start...");
Object[] args = invocation.getArgs();
MappedStatement mappedStatement = (MappedStatement) args[0];
BoundSql boundSql = mappedStatement.getSqlSource().getBoundSql(args[1]);
// 1.打印原sql
log.info("SqlMonitorInterceptor original sql: {}", boundSql.getSql().toLowerCase(Locale.CHINA).replace(SQL_IGNORED_CHARACTOR, " "));
// BoundSql就是封装myBatis最终产生的sql类
String sqlId = mappedStatement.getId();
// 获取节点的配置
Configuration configuration = mappedStatement.getConfiguration();
// 获取到最终的sql语句
log.info("SqlMonitorInterceptor sql after parse : {}", getSql(configuration, boundSql, sqlId));
return invocation.proceed();
}
// 如果参数是String,则添加单引号, 如果是日期,则转换为时间格式器并加单引号;对参数是null和不是null的情况作了处理
private static String getParameterValue(Object obj) {
String value = null;
if (obj instanceof String) {
value = "'" + obj.toString() + "'";
} else if (obj instanceof Date) {
DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT,
DateFormat.DEFAULT, Locale.CHINA);
value = "'" + formatter.format(new Date()) + "'";
} else {
if (obj != null) {
value = obj.toString();
} else {
value = "";
}
}
return value;
}
/**
* 进行 ?符号的替换
* @param configuration
* @param boundSql
* @return
*/
private static String parseSql(Configuration configuration, BoundSql boundSql) {
// 获取参数
Object parameterObject = boundSql.getParameterObject();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
// sql语句中多个空格都用一个空格代替
String sql = boundSql.getSql().replaceAll("[\\s]+", " ");
if (!CollectionUtils.isEmpty(parameterMappings) && parameterObject != null) {
// 获取类型处理器注册器,类型处理器的功能是进行java类型和数据库类型的转换
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
// 如果根据parameterObject.getClass()可以找到对应的类型,则替换
if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
sql = sql.replaceFirst("\\?",
Matcher.quoteReplacement(getParameterValue(parameterObject)));
} else {
// MetaObject主要是封装了originalObject对象,提供了get和set的方法用于获取和设置originalObject的属性值,主要支持对JavaBean、Collection、Map三种类型对象的操作
MetaObject metaObject = configuration.newMetaObject(parameterObject);
for (ParameterMapping parameterMapping : parameterMappings) {
String propertyName = parameterMapping.getProperty();
if (metaObject.hasGetter(propertyName)) {
Object obj = metaObject.getValue(propertyName);
sql = sql.replaceFirst("\\?",
Matcher.quoteReplacement(getParameterValue(obj)));
} else if (boundSql.hasAdditionalParameter(propertyName)) {
// 该分支是动态sql
Object obj = boundSql.getAdditionalParameter(propertyName);
sql = sql.replaceFirst("\\?",
Matcher.quoteReplacement(getParameterValue(obj)));
} else {
// 打印出缺失,提醒该参数缺失并防止错位
sql = sql.replaceFirst("\\?", "缺失");
}
}
}
}
return sql;
}
/**
* 封装了一下sql语句,使得结果返回完整xml路径下的sql语句节点id + sql语句
* @param configuration
* @param boundSql
* @param sqlId
* @return
*/
private static String getSql(Configuration configuration, BoundSql boundSql, String sqlId)
{
String sql = parseSql(configuration, boundSql);
StringBuilder str = new StringBuilder(100);
str.append(sqlId);
str.append(":");
str.append(sql);
return str.toString();
}
}
自定义租户配置注解
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TenantIdInjectConfig {
boolean ignore() default false;
}
插件配置到SqlSession
/**
* Full(proxyBeanMethods = true) :proxyBeanMethods参数设置为true时即为:Full 全模式。该模式下注入容器中的同一个组件无论被取出多少次都是同一个bean实例,即单实例对象,在该模式下SpringBoot每次启动都会判断检查容器中是否存在该组件
* Lite(proxyBeanMethods = false) :proxyBeanMethods参数设置为false时即为:Lite 轻量级模式。该模式下注入容器中的同一个组件无论被取出多少次都是不同的bean实例,即多实例对象,在该模式下SpringBoot每次启动会跳过检查容器中是否存在该组件
*/
@Configuration(proxyBeanMethods = true)
@ConditionalOnProperty(prefix = "users.mybatis.custom", name = "interceptor", havingValue = "true")
@Slf4j
public class MybatisConfig implements InitializingBean {
@Bean
@Order(1)
public TenantIdInjectInterceptor mybatisInterceptor() {
log.info("TenantIdInjectInterceptor interceptor init...");
return new TenantIdInjectInterceptor();
}
/**
* @Order(0) 是为了保证 TenantIdInjectInterceptor拦截器先于sqlMonitorInterceptor拦截器执行
* @return
*/
@Bean
@Order(0)
public SqlMonitorInterceptor sqlMonitorInterceptor() {
log.info("SqlMonitorInterceptor interceptor init...");
return new SqlMonitorInterceptor();
}
@Override
public void afterPropertiesSet() throws Exception {
}
}
分析:
这里将插件定义bean,交给容器管理即可,Mybatis会自动检测并加载到SqlSession
配置中心
数据库配置 - users-dev.properties
## mybatis依赖
spring.datasource.url = jdbc:mysql://localhost:3306/mac_m1?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=12345678
#数据库名称
spring.datasource.name=mac_m1
#默认情况下mybatis是不开启SQL日志输出,需要手动配置,com.riant.dao为mapper文件夹路径
logging.level.com.riant.mybatis.dao=debug
#指定mapper的配置文件的路径是mapper文件夹下的所有 xml文件。
mybatis.mapper-locations=classpath:mapper/*.xml
服务接口和应用配置
配置中心读取配置-bootstrap.properties
# 使用配置中心的master分支的{service-name}-dev.properties配置内容
spring.cloud.config.discovery.enabled=true
spring.cloud.config.discovery.serviceId=config-server
spring.cloud.config.profile=dev
spring.cloud.config.label=master
服务Mybatis配置-application.properties
server.port=8891
## mybatis依赖
#指定mapper的配置文件的路径是mapper文件夹下的所有 xml文件。
mybatis.mapper-locations=classpath:mapper/*.xml
#默认情况下mybatis是不开启SQL日志输出,需要手动配置,com.riant.dao为mapper文件夹路径
logging.level.com.riant.mybatis.dao=debug
## 自定义sql拦截器
users.mybatis.custom.interceptor=true
controller
@RestController
public class MybatisController {
@Autowired
private UserService userService;
@PostMapping("/user_insert")
public UserDetail user_insert() {
UserDetail detail = UserDetail.builder()
.age(new Random().nextInt(100))
.email(new Random().nextInt(100000000) + "@qq.com")
.name("bryant" + new Random().nextInt(1111))
.tenantId(new Random().nextLong())
.build();
return userService.insert(detail);
}
@GetMapping("/user_select")
public UserDetail user_select(@RequestParam("id") Long id) {
return userService.getById(id);
}
}
service
public interface UserService {
UserDetail insert(UserDetail detail);
UserDetail getById(Long id);
void update(UserDetail detail);
void delete(Long id);
}
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetail insert(UserDetail detail) {
if (Objects.isNull(detail)) {
throw new RuntimeException("detail is null");
}
userMapper.insert(detail);
return detail;
}
@Override
public UserDetail getById(Long id) {
return userMapper.getById(id);
}
@Override
public void update(UserDetail detail) {
userMapper.updateById(detail);
}
@Override
public void delete(Long id) {
userMapper.deleteById(id);
}
}
mapper
@Repository
public interface UserMapper {
void insert(@Param("record") UserDetail user);
@TenantIdInjectConfig(ignore = true)
void updateById(@Param("record") UserDetail user);
@TenantIdInjectConfig(ignore = true)
void deleteById(@Param("id") Long id);
UserDetail getById(@Param("id") Long id);
}
model
@Data
@Builder
public class UserDetail implements Serializable {
private static final long serialVersionUID = 2235541748764244156L;
private Long id;
private String name;
private Integer age;
private String email;
private Long tenantId;
}
服务测试
注册服务
http://localhost:8881/,可以看到注册中心和user服务已经注册上去了。
增删改查
拦截器日志
2024-08-26 22:51:52.756 DEBUG [users,28ec46443db7b606,28ec46443db7b606,true] 3427 --- [nio-8891-exec-8] o.s.web.servlet.DispatcherServlet : GET "/user_select?id=1", parameters={masked}
2024-08-26 22:51:52.757 INFO [users,28ec46443db7b606,28ec46443db7b606,true] 3427 --- [nio-8891-exec-8] c.b.c.mysql.TenantIdInjectInterceptor : TenantIdInjectInterceptor interceptor start...
2024-08-26 22:51:52.758 INFO [users,28ec46443db7b606,28ec46443db7b606,true] 3427 --- [nio-8891-exec-8] c.b.config.mysql.SqlMonitorInterceptor : SqlMonitorInterceptor interceptor start...
2024-08-26 22:51:52.758 INFO [users,28ec46443db7b606,28ec46443db7b606,true] 3427 --- [nio-8891-exec-8] c.b.config.mysql.SqlMonitorInterceptor : SqlMonitorInterceptor original sql: select
`id`, `name`, age, email, tenant_id
from user
where id = ?
and tenant_id = ?
2024-08-26 22:51:52.758 INFO [users,28ec46443db7b606,28ec46443db7b606,true] 3427 --- [nio-8891-exec-8] c.b.config.mysql.SqlMonitorInterceptor : SqlMonitorInterceptor sql after parse : com.bryant.mapper.UserMapper.getById:select `id`, `name`, age, email, tenant_id from user where id = 1 and tenant_id = 1
2024-08-26 22:51:52.761 DEBUG [users,28ec46443db7b606,28ec46443db7b606,true] 3427 --- [nio-8891-exec-8] o.s.web.servlet.DispatcherServlet : Completed 200 OK
总结
Mybatis的拦截器,原理上使用了非常多的设计模式,可以参考文章Mybatis链路分析:JDK动态代理和责任链模式的应用、Mybatis插件-租户ID的注入&拦截应用:
动态代理技术,Mybatis对执行器Executor使用了动态代理增强技术,从而让拦截器得以实现拦截功能;
责任链模式,利用拦截器chain,Executor的各个handler进行非侵入式拦截处理。
SpringBoot集成Mybatis,跟传统的开发有比较大的异同,挖个坑,下文我们继续剖析一下源码流程。