Mybatis拦截器实现自定义需求

2023-05-30 0 3,974

目录

前言

最近在参加金石计划,在考虑写什么的时,想到自己在项目中使用过的mybatis的插件,就想趁这个机会聊一聊我们接触频繁的Mybatis.

如果是使用过Mybatis的小伙伴,那么我们接触过的第一个Mybatis的插件自然就是分页插件(Mybatis-PageHelper)啦。

你有了解过它是如何实现的吗?你有没有自己编写 Mybatis 插件去实现一些自定义需求呢?

插件是一种常见的扩展方式,大多数开源框架也都支持用户通过添加自定义插件的方式来扩展或改变框架原有的功能。

Mybatis 中也提供了插件的功能,虽然叫插件,但是实际上是通过拦截器( Interceptor )实现的,通过拦截某些方法的调用,在执行目标逻辑之前插入我们自己的逻辑实现。另外在 MyBatis 的插件模块中还涉及责任链模式和 JDK 动态代理~

文章大纲:

Mybatis拦截器实现自定义需求

一、应用场景

  • 一些字段的自动填充
  • SQL语句监控、打印、数据权限等
  • 数据加解密操作、数据脱敏操作
  • 分页插件
  • 参数、结果集的类型转换

这些都是一些可以使用Mybatis插件实现的场景,当然也可以使用其他的方式来实现,只不过拦截的地方不一样罢了,有早有晚。

二、Mybatis实现自定义拦截器

我们用自定义拦截器实现一个相对简单的需求,在大多数表设计中,都会有create_time和update_time等字段,在创建或更新时需要更新相关字段。

如果是使用过MybatisPlus的小伙伴,可能知道在MybatisPlus中有一个自动填充功能,通过实现MetaObjectHandler接口中的方法来进行实现(主要的实现代码在com.baomidou.mybatisplus.core.MybatisParameterHandler中).

但使用Mybatis,并没有相关的方法或 API 可以直接来实现。所以我们这次就用以此处作为切入点,自定义拦截器来实现类似的自动填充功能。

编写步骤

  • 编写一个拦截器类实现 Interceptor 接口
  • 添加拦截注解 @Intercepts
  • 在xml文件中配置拦截器或者添加到Configuration中

基础的环境我就不再贴出来啦哈,直接上三个步骤的代码

2.1、编写拦截器

package com.nzc.interceptor;
​
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.springframework.beans.factory.annotation.Value;
​
import java.lang.reflect.Field;
import java.util.*;
​
/**
 * @author 宁在春
 * @version 1.0
 * @description: 通过实现拦截器来实现部分字段的自动填充功能
 * @date 2023/4/6 21:49
 */
@Intercepts({
        @Signature(type = Executor.class, method = \"update\", args = {MappedStatement.class, Object.class})
})
@Slf4j
public class MybatisMetaInterceptor implements Interceptor {
​
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        String sqlId = mappedStatement.getId();
        log.info(\"------sqlId------\" + sqlId);
        SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
        Object parameter = invocation.getArgs()[1];
        log.info(\"------sqlCommandType------\" + sqlCommandType);
        log.info(\"拦截查询请求 Executor#update 方法\" + invocation.getMethod());
        if (parameter == null) {
            return invocation.proceed();
        }
        if (SqlCommandType.INSERT == sqlCommandType) {
​
            Field[] fields = getAllFields(parameter);
            for (Field field : fields) {
                log.info(\"------field.name------\" + field.getName());
                try {
                    // 注入创建时间
                    if (\"createTime\".equals(field.getName())) {
                        field.setAccessible(true);
                        Object local_createDate = field.get(parameter);
                        field.setAccessible(false);
                        if (local_createDate == null || local_createDate.equals(\"\")) {
                            field.setAccessible(true);
                            field.set(parameter, new Date());
                            field.setAccessible(false);
                        }
                    }
                } catch (Exception e) {
                }
            }
        }
        if (SqlCommandType.UPDATE == sqlCommandType) {
            Field[] fields = getAllFields(parameter);
            for (Field field : fields) {
                log.info(\"------field.name------\" + field.getName());
                try {
                    if (\"updateTime\".equals(field.getName())) {
                        field.setAccessible(true);
                        field.set(parameter, new Date());
                        field.setAccessible(false);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
        return invocation.proceed();
    }
​
    @Override
    public Object plugin(Object target) {
        return Interceptor.super.plugin(target);
    }
    // 稍后会展开说的
    @Override
    public void setProperties(Properties properties) {
        System.out.println(\"=======begin\");
        System.out.println(properties.getProperty(\"param1\"));
        System.out.println(properties.getProperty(\"param2\"));
        Interceptor.super.setProperties(properties);
        System.out.println(\"=======end\");
    }
​
    /**
     * 获取类的所有属性,包括父类
     *
     * @param object
     * @return
     */
    public static Field[] getAllFields(Object object) {
        Class<?> clazz = object.getClass();
        List<Field> fieldList = new ArrayList<>();
        while (clazz != null) {
            fieldList.addAll(new ArrayList<>(Arrays.asList(clazz.getDeclaredFields())));
            clazz = clazz.getSuperclass();
        }
        Field[] fields = new Field[fieldList.size()];
        fieldList.toArray(fields);
        return fields;
    }
}
​

2.2、添加到Mybatis配置

我这里使用的JavaConfig的方式

package com.nzc.config;
​
import com.nzc.interceptor.*;
import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
​
@Configuration
public class MyBatisConfig {
​
    @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        return new ConfigurationCustomizer() {
            @Override
            public void customize(org.apache.ibatis.session.Configuration configuration) {
                // 开启驼峰命名映射
                configuration.setMapUnderscoreToCamelCase(true);
                MybatisMetaInterceptor mybatisMetaInterceptor = new MybatisMetaInterceptor();
                Properties properties = new Properties();
                properties.setProperty(\"param1\",\"javaconfig-value1\");
                properties.setProperty(\"param2\",\"javaconfig-value2\");
                mybatisMetaInterceptor.setProperties(properties);
                configuration.addInterceptor(mybatisMetaInterceptor);
            }
        };
    }
}

如果是xml配置的话,则是如下: property 是设置 拦截器中需要用到的参数

<configuration>
    <plugins>
        <plugin interceptor=\"com.nzc.interceptor.MybatisMetaInterceptor\"> 
            <property name=\"param1\" value=\"value1\"/>
            <property name=\"param2\" value=\"value2\"/>
        </plugin>
    </plugins>
</configuration>    

2.3、测试

测试代码:实现了一个SysMapper的增删改查

package com.nzc.mapper;
​​
import com.nzc.entity.SysUser;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
​
import java.util.List;
​
/**
 * @author 宁在春
 * @description 针对表【sys_user】的数据库操作Mapper
 */
@Mapper
public interface SysUserMapper {
​
    @Select(\"SELECT * FROM tb_sys_user\")
    List<SysUser> list();
​
    @Insert(\"insert into tb_sys_user(id,username,realname,create_time,update_time) values (#{id}, #{username}, #{realname}, #{createTime}, #{updateTime})\")
    Boolean insert(SysUser sysUser);
​
    @Update(\"update tb_sys_user set  username=#{username} , realname=#{realname},update_time=#{updateTime}  where id=#{id}\")
    boolean update(SysUser sysUser);
}
/**
 * @author 宁在春
 * @version 1.0
 * @description: TODO
 * @date 2023/4/6 21:38
 */
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class SysUserMapperTest {
​
    @Autowired
    private SysUserMapper sysUserMapper;
​
​
    @Test
    public void test1(){
        System.out.println(sysUserMapper.list());
    }
​
    @Test
    public void test2(){
        SysUser sysUser = new SysUser();
        sysUser.setId(\"1235\");
        sysUser.setUsername(\"nzc5\");
        sysUser.setRealname(\"nzc5\");
        System.out.println(sysUserMapper.insert(sysUser));
    }
​
    @Test
    public void test3(){
        SysUser sysUser = new SysUser();
        sysUser.setId(\"1235\");
        sysUser.setUsername(\"nzc7\");
        sysUser.setRealname(\"nzc5\");
        System.out.println(sysUserMapper.update(sysUser));
    }
}
​

当然重点不在这里,而是在我们打印的日志上,一起来看看效果吧

Mybatis拦截器实现自定义需求

此处相关日志对应Interceptor中的日志打印,想要了解的更为详细的可以debug查看一番。

2.4、小结

通过这个小小的案例,我想大伙对于Mybatis中的拦截器应当是没有那般陌生了吧,接下来再来仔细聊聊吧

如果你使用过MybatisPlus的话,在读完这篇博文后,可以思考思考下面这个问题,或去看一看源码,将知识串联起来,如果可以的话,记得把答案贴到评论区啦~~~

思考:还记得这一小节开始我们聊到的MybatisPlus实现的自动填充功能吗?它是怎么实现的呢?

三、拦截器接口介绍

MyBatis 插件可以用来实现拦截器接口 Interceptor ,在实现类中对拦截对象和方法进行处理

public interface Interceptor {
  // 执行拦截逻辑的方法
  Object intercept(Invocation invocation) throws Throwable;
​
    //这个方法的参数 target 就是拦截器要拦截的对象,该方法会在创建被拦截的接口实现类时被调用。
    //该方法的实现很简单 ,只需要调用 MyBatis 提供的 Plug 类的 wrap 静态方法就可以通过 Java 动态代理拦截目标对象。
  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }
​
  //这个方法用来传递插件的参数,可以通过参数来改变插件的行为
  default void setProperties(Properties properties) {
    // NOP
  }
}

有点懵没啥事,一个一个展开说:

intercept 方法

Object intercept(Invocation invocation) throws Throwable;

简单说就是执行拦截逻辑的方法,但不得不说这句话是个高度概括~

首先我们要明白参数Invocation是个什么东东:

public class Invocation {
​
  private final Object target; // 拦截的对象信息
  private final Method method; // 拦截的方法信息
  private final Object[] args; // 拦截的对象方法中的参数
​
  public Invocation(Object target, Method method, Object[] args) {
    this.target = target;
    this.method = method;
    this.args = args;
  }
​
 // get...
 // 利用反射来执行拦截对象的方法
  public Object proceed() throws InvocationTargetException, IllegalAccessException {
    return method.invoke(target, args);
  }
}

联系我们之前实现的自定义拦截器上的注解:

@Intercepts({
        @Signature(type = Executor.class, method = \"update\", args = {MappedStatement.class, Object.class})
})
  • target对应我们拦截的Executor对象
  • method对应Executor#update方法
  • args对应Executor#update#args参数

plugin方法

这个方法其实也很好说:

那就是Mybatis在创建拦截器代理时候会判断一次,当前这个类 Interceptor 到底需不需要生成一个代理进行拦截,如果需要拦截,就生成一个代理对象,这个代理就是一个 {@link Plugin},它实现了jdk的动态代理接口 {@link InvocationHandler},如果不需要代理,则直接返回目标对象本身 加载时机:该方法在 mybatis 加载核心配置文件时被调用

  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }
public class Plugin implements InvocationHandler {
​
​
    //  利用反射,获取这个拦截器 MyInterceptor 的注解 Intercepts和Signature,然后解析里面的值,
    //  1 先是判断要拦截的对象是哪一个
    //  2 然后根据方法名称和参数判断要对哪一个方法进行拦截
    //  3 根据结果做出决定,是返回一个对象呢还是代理对象
    public static Object wrap(Object target, Interceptor interceptor) {
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
        Class<?> type = target.getClass();
        // 这边就是判断当前的interceptor是否包含在
        Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
        if (interfaces.length > 0) {
            return Proxy.newProxyInstance(
                type.getClassLoader(),
                interfaces,
                new Plugin(target, interceptor, signatureMap));
        }
        //如果不需要代理,则直接返回目标对象本身
        return target;
    }
​
    //....
​
}

setProperties方法

在拦截器中可能需要使用到一些变量参数,并且这个参数是可配置的,这个时候我们就可以使用这个方法啦,加载时机:该方法在 mybatis 加载核心配置文件时被调用

  default void setProperties(Properties properties) {
    // NOP
  }

关于如何使用:

javaConfig方式设置:

@Bean
public ConfigurationCustomizer configurationCustomizer() {
    return new ConfigurationCustomizer() {
        @Override
        public void customize(org.apache.ibatis.session.Configuration configuration) {
            // 开启驼峰命名映射
            configuration.setMapUnderscoreToCamelCase(true);
            MybatisMetaInterceptor mybatisMetaInterceptor = new MybatisMetaInterceptor();
            Properties properties = new Properties();
            properties.setProperty(\"param1\",\"javaconfig-value1\");
            properties.setProperty(\"param2\",\"javaconfig-value2\");
            mybatisMetaInterceptor.setProperties(properties);
            configuration.addInterceptor(mybatisMetaInterceptor);
        }
    };
}

通过mybatis-config.xml文件进行配置

<configuration>
    <plugins>
        <plugin interceptor=\"com.nzc.interceptor.MybatisMetaInterceptor\">
            <property name=\"param1\" value=\"value1\"/>
            <property name=\"param2\" value=\"value2\"/>
        </plugin>
    </plugins>
</configuration>    

测试效果就是测试案例上那般,通过了解拦截器接口的信息,对于之前的案例不再是那般模糊啦

接下来再接着聊一聊拦截器上面那一坨注解信息是用来干嘛的吧,

注意

当配置多个拦截器时, MyBatis 会遍历所有拦截器,按顺序执行拦截器的 plugin 口方法, 被拦截的对象就会被层层代理。

在执行拦截对象的方法时,会一层层地调用拦截器,拦截器通 invocation proceed()调用下层的方法,直到真正的方法被执行。

方法执行的结果 从最里面开始向外 层层返回,所以如果存在按顺序配置的三个签名相同的拦截器, MyBaits 会按照 C>B>A>target.proceed()>A>B>C 的顺序执行。如果签名不同, 就会按照 MyBatis 拦截对象的逻辑执行.

这也是我们最开始谈到的Mybatis插件模块所使用的设计模式-责任链模式。

四、拦截器注解介绍

上一个章节,我们只说明如何实现Interceptor接口来实现拦截,却没有说明要拦截的对象是谁,在什么时候进行拦截.就关系到我们之前编写的注解信息啦.

@Intercepts({
        @Signature(type = Executor.class, method = \"update\", args = {MappedStatement.class, Object.class})
})

这两个注解用来配置拦截器要拦截的接口的方法。

@Intercepts({})注解中是一个@Signature()数组,可以在一个拦截器中同时拦截不同的接口和方法。

MyBatis 允许在己映射语句执行过程中的某一点进行拦截调用。默认情况下, MyBatis 允许使用插件来拦截的接口包括以下几个。

  • Executor
  • ParameterHandler
  • ResultSetHandler
  • StatementHandler

@Signature 注解包含以下三个属性。

  • type 设置拦截接口,可选值是前面提到的4个接口
  • method 设置拦截接口中的方法名 可选值是前面4个接口中所对应的方法,需要和接口匹配
  • args 设置拦截方法的参数类型数组 通过方法名和参数类型可以确定唯一一个方法

Executor 接口

下面就是Executor接口的类信息

public interface Executor {
​
  int update(MappedStatement ms, Object parameter) throws SQLException;
​
  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;
​
  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;
​
  <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;
​
  List<BatchResult> flushStatements() throws SQLException;
​
  void commit(boolean required) throws SQLException;
​
  void rollback(boolean required) throws SQLException;
​
  CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql);
​
  boolean isCached(MappedStatement ms, CacheKey key);
​
  void clearLocalCache();
​
  void deferLoad(MappedStatement ms, MetaObject resultObject, String property, CacheKey key, Class<?> targetType);
​
  Transaction getTransaction();
​
  void close(boolean forceRollback);
​
  boolean isClosed();
​
  void setExecutorWrapper(Executor executor);
​
}

我只会简单说一些最常用的~

1、update

int update(MappedStatement ms, Object parameter) throws SQLException;

该方法会在所有的 INSERT、UPDATE、DELETE 执行时被调用,因此如果想要拦截这类操作,可以拦截该方法。接口方法对应的签名如下。

@Intercepts({
        @Signature(type = Executor.class, method = \"update\", args = {MappedStatement.class, Object.class})
})

2、query

<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;
​
<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;

该方法会在所有 SELECT 查询方法执行时被调用 通过这个接口参数可以获取很多有用的信息,这也是最常被拦截的方法。

@Intercepts({@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}
)})

3、queryCursor:

  <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;

该方法只有在查询 的返回值类型为 Cursor 时被调用 。接口方法对应的签名类似于之前的。

//该方法只在通过 SqlSession 方法调用 commit 方法时才被调用  
void commit(boolean required) throws SQLException; 
//该方法只在通过 SqlSessio口方法调用 rollback 方法时才被调用
void rollback(boolean required) throws SQLException;
//该方法只在通过 SqlSession 方法获取数据库连接时才被调用,
Transaction getTransaction();
//该方法只在延迟加载获取新的 Executor 后才会被执行
void close(boolean forceRollback);
//该方法只在延迟加载执行查询方法前被执行
boolean isClosed();

注解的编写方法都是类似的。

ParameterHandler 接口

public interface ParameterHandler {
​
    //该方法只在执行存储过程处理出参的时候被调用
    Object getParameterObject();
    //该方法在所有数据库方法设置 SQL 参数时被调用。
    void setParameters(PreparedStatement ps) throws SQLException;
}

我都写一块啦,如果要拦截某一个的话只写一个即可

@Intercepts({
        @Signature(type = ParameterHandler.class, method = \"getParameterObject\", args = {}),
        @Signature(type = ParameterHandler.class, method = \"setParameters\", args = {PreparedStatement.class})
})

ResultSetHandler 接口

public interface ResultSetHandler {
    //该方法会在除存储过程及返回值类型为 Cursor 以外的查询方法中被调用。
    <E> List<E> handleResultSets(Statement stmt) throws SQLException;
    //只会在返回值类型为 ursor 查询方法中被调用  
    <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;
    //只在使用存储过程处理出参时被调用 ,
    void handleOutputParameters(CallableStatement cs) throws SQLException;
}
@Intercepts({
        @Signature(type = ResultSetHandler.class, method = \"handleResultSets\", args = {Statement.class}),
        @Signature(type = ResultSetHandler.class, method = \"handleCursorResultSets\", args = {Statement.class}),
        @Signature(type = ResultSetHandler.class, method = \"handleOutputParameters\", args = {CallableStatement.class})
})

StatementHandler 接口

public interface StatementHandler {
    //该方法会在数据库执行前被调用 优先于当前接口中的其他方法而被执行
    Statement prepare(Connection connection, Integer transactionTimeout)
        throws SQLException;
    //该方法在 prepare 方法之后执行,用于处理参数信息 
    void parameterize(Statement statement)
        throws SQLException;
    //在全局设置配置 defaultExecutorType BATCH 时,执行数据操作才会调用该方法
    void batch(Statement statement)
        throws SQLException;
    //执行UPDATE、DELETE、INSERT方法时执行
    int update(Statement statement)
        throws SQLException;
    //执行 SELECT 方法时调用,接口方法对应的签名如下。
    <E> List<E> query(Statement statement, ResultHandler resultHandler)
        throws SQLException;
​
    <E> Cursor<E> queryCursor(Statement statement)
        throws SQLException;
​
    //获取实际的SQL字符串
    BoundSql getBoundSql();
​
    ParameterHandler getParameterHandler();
​
}
@Intercepts({
        @Signature(type = StatementHandler.class, method = \"prepare\", args = {Connection.class,Integer.class}),
        @Signature(type = StatementHandler.class, method = \"parameterize\", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = \"batch\", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = \"update\", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = \"query\", args = {Statement.class,ResultHandler.class}),
        @Signature(type = StatementHandler.class, method = \"queryCursor\", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = \"getBoundSql\", args = {}),
        @Signature(type = StatementHandler.class, method = \"getParameterHandler\", args = {})
}

如果有时间的话,我会更加建议看了的小伙伴,自己去实现接口做个测试,验证一番,也能了解的更彻底些。看会了,很多时候知识的记忆还是浅的。

五、进一步思考

看完这篇文章后,不知道你有没有什么收获。

再次看看这张文章大纲的图吧

Mybatis拦截器实现自定义需求

试着思考思考下面几个问题:

Mybatis插件适用于哪些场景?回忆一下你做过的项目,是否有可以使用Mybatis插件来实现的呢?你可以编写一个Mybatis插件了吗?感兴趣的话,你可以试着去了解一下Mybatis分页插件的实现方式。

最后留下一个遇到的问题,也是下一篇文章可能会写的吧,同时也使用到了今天所谈到了的拦截器。

在项目中,你们都是如何针对表中某些字段进行加解密的呢?

资源下载此资源下载价格为1小猪币,终身VIP免费,请先
由于本站资源来源于互联网,以研究交流为目的,所有仅供大家参考、学习,不存在任何商业目的与商业用途,如资源存在BUG以及其他任何问题,请自行解决,本站不提供技术服务! 由于资源为虚拟可复制性,下载后不予退积分和退款,谢谢您的支持!如遇到失效或错误的下载链接请联系客服QQ:442469558

:本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可, 转载请附上原文出处链接。
1、本站提供的源码不保证资源的完整性以及安全性,不附带任何技术服务!
2、本站提供的模板、软件工具等其他资源,均不包含技术服务,请大家谅解!
3、本站提供的资源仅供下载者参考学习,请勿用于任何商业用途,请24小时内删除!
4、如需商用,请购买正版,由于未及时购买正版发生的侵权行为,与本站无关。
5、本站部分资源存放于百度网盘或其他网盘中,请提前注册好百度网盘账号,下载安装百度网盘客户端或其他网盘客户端进行下载;
6、本站部分资源文件是经压缩后的,请下载后安装解压软件,推荐使用WinRAR和7-Zip解压软件。
7、如果本站提供的资源侵犯到了您的权益,请邮件联系: 442469558@qq.com 进行处理!

猪小侠源码-最新源码下载平台 Java教程 Mybatis拦截器实现自定义需求 http://www.20zxx.cn/763342/xuexijiaocheng/javajc.html

猪小侠源码,优质资源分享网

常见问题
  • 本站所有资源版权均属于原作者所有,均只能用于参考学习,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担
查看详情
  • 最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,建议提前注册好百度网盘账号,使用百度网盘客户端下载
查看详情

相关文章

官方客服团队

为您解决烦忧 - 24小时在线 专业服务