springboot2结合mybatis拦截器实现主键自动生成

前言

前阵子和朋友聊天,他说他们项目有个需求,要实现主键自动生成,不想每次新增的时候,都手动设置主键。于是我就问他,那你们数据库表设置主键自动递增不就得了。他的回答是他们项目目前的id都是采用雪花算法来生成,因此为了项目稳定性,不会切换id的生成方式。

朋友问我有没有什么实现思路,他们公司的orm框架是mybatis,我就建议他说,不然让你老大把mybatis切换成mybatis-plus。mybatis-plus就支持注解式的id自动生成,而且mybatis-plus只是对mybatis进行增强不做改变。朋友还是那句话,说为了项目稳定,之前项目组没有使用mybatis-plus的经验,贸然切换不知道会不会有什么坑。后面没招了,我就跟他说不然你用mybatis的拦截器实现一个吧。于是又有一篇吹水的创作题材出现。

前置知识

在介绍如何通过mybatis拦截器实现主键自动生成之前,我们先来梳理一些知识点

1、mybatis拦截器的作用

mybatis拦截器设计的初衷就是为了供用户在某些时候可以实现自己的逻辑而不必去动mybatis固有的逻辑

2、Interceptor拦截器

每个自定义拦截器都要实现

org.apache.ibatis.plugin.Interceptor

这个接口,并且自定义拦截器类上添加@Intercepts注解

3、拦截器能拦截哪些类型

  • Executor:拦截执行器的方法。

  • ParameterHandler:拦截参数的处理。

  • ResultHandler:拦截结果集的处理。

  • StatementHandler:拦截Sql语法构建的处理。

4、拦截的顺序

a、不同类型拦截器的执行顺序

Executor -> ParameterHandler -> StatementHandler -> ResultSetHandler

b、多个拦截器拦截同种类型同一个目标方法,执行顺序是后配置的拦截器先执行

比如在mybatis配置如下

    <plugins>
        <plugin interceptor="com.lybgeek.InterceptorA" />
        <plugin interceptor="com.lybgeek.InterceptorB" />
    </plugins>

则InterceptorB先执行。

如果是和spring做了集成,先注入spring ioc容器的拦截器,则后执行。比如有个mybatisConfig,里面有如下拦截器bean配置

 @Bean
    public InterceptorA interceptorA(){
        return new InterceptorA();
    }

    @Bean
    public InterceptorB interceptorB(){
        return new InterceptorB();
    }

则InterceptorB先执行。当然如果你是直接用@Component注解这形式,则可以配合@Order注解来控制加载顺序

5、拦截器注解介绍

@Intercepts:标识该类是一个拦截器

@Signature:指明自定义拦截器需要拦截哪一个类型,哪一个方法。
@Signature注解属性中的type表示对应可以拦截四种类型(Executor、ParameterHandler、ResultHandler、StatementHandler)中的一种;method表示对应类型(Executor、ParameterHandler、ResultHandler、StatementHandler)中的哪类方法;args表示对应method中的参数类型

6、拦截器方法介绍

a、 intercept方法

public Object intercept(Invocation invocation) throws Throwable

这个方法就是我们来执行我们自己想实现的业务逻辑,比如我们的主键自动生成逻辑就是在这边实现。

Invocation这个类中的成员属性target就是@Signature中的type;method就是@Signature中的method;args就是@Signature中的args参数类型的具体实例对象

b、 plugin方法

public Object plugin(Object target)

这个是用返回代理对象或者是原生代理对象,如果你要返回代理对象,则返回值可以设置为

Plugin.wrap(target, this);
this为拦截器

如果返回是代理对象,则会执行拦截器的业务逻辑,如果直接返回target,就是没有拦截器的业务逻辑。说白了就是告诉mybatis是不是要进行拦截,如果要拦截,就生成代理对象,不拦截是生成原生对象

c、 setProperties方法

public void setProperties(Properties properties)

用于在Mybatis配置文件中指定一些属性

主键自动生成思路

1、定义一个拦截器

主要拦截

 `Executor#update(MappedStatement ms, Object parameter)`} 

这个方法。mybatis的insert、update、delete都是通过这个方法,因此我们通过拦截这个这方法,来实现主键自动生成。其代码块如下

@Intercepts(value={@Signature(type = Executor.class,method = "update",args = {MappedStatement.class,Object.class})})
public class AutoIdInterceptor implements Interceptor {}

2、判断sql操作类型

Executor 提供的方法中,update 包含了 新增,修改和删除类型,无法直接区分,需要借助 MappedStatement 类的属性 SqlCommandType 来进行判断,该类包含了所有的操作类型

public enum SqlCommandType {
  UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH;
}

当SqlCommandType类型是insert我们才进行主键自增操作

3、填充主键值

3.1、编写自动生成id注解

Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AutoId {
    /**
     * 主键名
     * @return
     */
    String primaryKey();

    /**
     * 支持的主键算法类型
     * @return
     */
    IdType type() default IdType.SNOWFLAKE;

    enum IdType{
        SNOWFLAKE
    }
}

3.2、 雪花算法实现

我们可以直接拿hutool这个工具包提供的idUtil来直接实现算法。

引入

 <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
        </dependency>
Snowflake snowflake = IdUtil.createSnowflake(0,0);
long value = snowflake.nextId();

3.3、填充主键值

其实现核心是利用反射。其核心代码片段如下

   ReflectionUtils.doWithFields(entity.getClass(), field->{
                    ReflectionUtils.makeAccessible(field);
                    AutoId autoId = field.getAnnotation(AutoId.class);
                    if(!ObjectUtils.isEmpty(autoId) && (field.getType().isAssignableFrom(Long.class))){
                        switch (autoId.type()){
                            case SNOWFLAKE:
                                SnowFlakeAutoIdProcess snowFlakeAutoIdProcess = new SnowFlakeAutoIdProcess(field);
                                snowFlakeAutoIdProcess.setPrimaryKey(autoId.primaryKey());
                                finalIdProcesses.add(snowFlakeAutoIdProcess);
                                break;
                        }
                    }
                });
public class SnowFlakeAutoIdProcess extends BaseAutoIdProcess {

    private static Snowflake snowflake = IdUtil.createSnowflake(0,0);


    public SnowFlakeAutoIdProcess(Field field) {
        super(field);
    }

    @Override
    void setFieldValue(Object entity) throws Exception{
        long value = snowflake.nextId();
        field.set(entity,value);
    }
}

如果项目中的mapper.xml已经的insert语句已经含有id,比如

insert into sys_test( `id`,`type`, `url`,`menu_type`,`gmt_create`)values( #{id},#{type}, #{url},#{menuType},#{gmtCreate})

则只需到填充id值这一步。拦截器的任务就完成。如果mapper.xml的insert不含id,形如

insert into sys_test( `type`, `url`,`menu_type`,`gmt_create`)values( #{type}, #{url},#{menuType},#{gmtCreate})

则还需重写insert语句以及新增id参数

4、重写insert语句以及新增id参数(可选)

4.1 重写insert语句

方法一:
从 MappedStatement 对象中获取 SqlSource 对象,再从从 SqlSource 对象中获取获取 BoundSql 对象,通过 BoundSql#getSql 方法获取原始的sql,最后在原始sql的基础上追加id

方法二:

引入

<dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>${druid.version}</version>
        </dependency>

通过

com.alibaba.druid.sql.dialect.mysql.parser.MySqlStatementParser

获取相应的表名、需要insert的字段名。然后重新拼凑出新的insert语句

4.2 把新的sql重置给Invocation

其核心实现思路是创建一个新的MappedStatement,新的MappedStatement绑定新sql,再把新的MappedStatement赋值给Invocation的args[0],代码片段如下

 private void resetSql2Invocation(Invocation invocation, BoundSqlHelper boundSqlHelper,Object entity) throws SQLException {
        final Object[] args = invocation.getArgs();
        MappedStatement statement = (MappedStatement) args[0];
        MappedStatement newStatement = newMappedStatement(statement, new BoundSqlSqlSource(boundSqlHelper));
        MetaObject msObject =  MetaObject.forObject(newStatement, new DefaultObjectFactory(), new DefaultObjectWrapperFactory(),new DefaultReflectorFactory());
        msObject.setValue("sqlSource.boundSqlHelper.boundSql.sql", boundSqlHelper.getSql());

            args[0] = newStatement;

    }

4.3 新增id参数

其核心是利用

org.apache.ibatis.mapping.ParameterMapping

核心代码片段如下

  private void setPrimaryKeyParaterMapping(String primaryKey) {
           ParameterMapping parameterMapping = new ParameterMapping.Builder(boundSqlHelper.getConfiguration(),primaryKey,boundSqlHelper.getTypeHandler()).build();
           boundSqlHelper.getBoundSql().getParameterMappings().add(parameterMapping);
       }

5、将mybatis拦截器注入到spring容器

可以直接在拦截器上加

@org.springframework.stereotype.Component

注解。也可以通过

 @Bean
    public AutoIdInterceptor autoIdInterceptor(){
        return new AutoIdInterceptor();
    }

6、在需要实现自增主键的实体字段上加如下注解

@AutoId(primaryKey = "id")
    private Long id;

测试

1、对应的测试实体以及单元测试代码如下

@Data
public class TestDO implements Serializable {
    private static final long serialVersionUID = 1L;

    @AutoId(primaryKey = "id")
    private Long id;
    private Integer type;
    private String url;
    private Date gmtCreate;
    private String menuType;
}
@Autowired
    private TestService testService;

    @Test
    public void testAdd(){
        TestDO testDO = new TestDO();
        testDO.setType(1);
        testDO.setMenuType("1");
        testDO.setUrl("www.test.com");
        testDO.setGmtCreate(new Date());
        testService.save(testDO);
        testService.get(110L);
    }

    @Test
    public void testBatch(){
        List<TestDO> testDOList = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            TestDO testDO = new TestDO();
            testDO.setType(i);
            testDO.setMenuType(i+"");
            testDO.setUrl("www.test"+i+".com");
            testDO.setGmtCreate(new Date());
            testDOList.add(testDO);
        }

        testService.saveBatch(testDOList);
    }

2、当mapper的insert语句中含有id,形如下

<insert id="save" parameterType="com.lybgeek.TestDO" useGeneratedKeys="true" keyProperty="id">
        insert into sys_test(`id`,`type`, `url`,`menu_type`,`gmt_create`)
        values( #{id},#{type}, #{url},#{menuType},#{gmtCreate})
    </insert>

以及批量插入sql

<insert id="saveBatch"  parameterType="java.util.List" useGeneratedKeys="false">
        insert into sys_test( `id`,`gmt_create`,`type`,`url`,`menu_type`)
        values
        <foreach collection="list" item="test" index="index" separator=",">
            ( #{test.id},#{test.gmtCreate},#{test.type}, #{test.url},
            #{test.menuType})
        </foreach>
    </insert>

查看控制台sql打印语句

15:52:04 [main] DEBUG com.lybgeek.dao.TestDao.save - ==>  Preparing: insert into sys_test(`id`,`type`, `url`,`menu_type`,`gmt_create`) values( ?,?, ?,?,? ) 
15:52:04 [main] DEBUG com.lybgeek.dao.TestDao.save - ==> Parameters: 356829258376544258(Long), 1(Integer), www.test.com(String), 1(String), 2020-09-11 15:52:04.738(Timestamp)
15:52:04 [main] DEBUG com.nlybgeek.dao.TestDao.save - <==    Updates: 1
15:52:04 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - ==>  Preparing: insert into sys_test( `id`,`gmt_create`,`type`,`url`,`menu_type`) values ( ?,?,?, ?, ?) , ( ?,?,?, ?, ?) , ( ?,?,?, ?, ?) 
15:52:04 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - ==> Parameters: 356829258896637961(Long), 2020-09-11 15:52:04.847(Timestamp), 0(Integer), www.test0.com(String), 0(String), 356829258896637960(Long), 2020-09-11 15:52:04.847(Timestamp), 1(Integer), www.test1.com(String), 1(String), 356829258896637962(Long), 2020-09-11 15:52:04.847(Timestamp), 2(Integer), www.test2.com(String), 2(String)
15:52:04 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - <==    Updates: 3

查看数据库


sql语句.png

3、当mapper的insert语句中不含id,形如下

<insert id="save" parameterType="com.lybgeek.TestDO" useGeneratedKeys="true" keyProperty="id">
        insert into sys_test(`type`, `url`,`menu_type`,`gmt_create`)
        values(#{type}, #{url},#{menuType},#{gmtCreate})
    </insert>

以及批量插入sql

<insert id="saveBatch"  parameterType="java.util.List" useGeneratedKeys="false">
        insert into sys_test(`gmt_create`,`type`,`url`,`menu_type`)
        values
        <foreach collection="list" item="test" index="index" separator=",">
            (#{test.gmtCreate},#{test.type}, #{test.url},
            #{test.menuType})
        </foreach>
    </insert>

查看控制台sql打印语句

15:59:46 [main] DEBUG com.lybgeek.dao.TestDao.save - ==>  Preparing: insert into sys_test(`type`,`url`,`menu_type`,`gmt_create`,id) values (?,?,?,?,?) 
15:59:46 [main] DEBUG com.lybgeek.dao.TestDao.save - ==> Parameters: 1(Integer), www.test.com(String), 1(String), 2020-09-11 15:59:46.741(Timestamp), 356831196144992264(Long)
15:59:46 [main] DEBUG com.lybgeek.dao.TestDao.save - <==    Updates: 1
15:59:46 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - ==>  Preparing: insert into sys_test(`gmt_create`,`type`,`url`,`menu_type`,id) values (?,?,?,?,?),(?,?,?,?,?),(?,?,?,?,?) 
15:59:46 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - ==> Parameters: 2020-09-11 15:59:46.845(Timestamp), 0(Integer), www.test0.com(String), 0(String), 356831196635725829(Long), 2020-09-11 15:59:46.845(Timestamp), 1(Integer), www.test1.com(String), 1(String), 356831196635725828(Long), 2020-09-11 15:59:46.845(Timestamp), 2(Integer), www.test2.com(String), 2(String), 356831196635725830(Long)
15:59:46 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - <==    Updates: 3

从控制台我们可以看出,当mapper.xml没有配置id字段时,则拦截器会自动帮我们追加id字段

查看数据库


sql语句1.png

总结

本文虽然是介绍mybatis拦截器实现主键自动生成,但文中更多讲解如何实现一个拦截器以及主键生成思路,并没把intercept实现主键方法贴出来。其原因主要是主键自动生成在mybatis-plus里面就有实现,其次是有思路后,大家就可以自己实现了。最后对具体实现感兴趣的朋友,可以查看文末中demo链接

参考文档

mybatis拦截器
mybatis插件实现自定义改写表名
mybatis拦截器,动态修改sql语句

demo链接

https://github.com/lyb-geek/springboot-learning/tree/master/springboot-mybatis-autoId

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345