IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> 2.19.1-mybatis-plus分页源码分析 -> 正文阅读

[Java知识库]2.19.1-mybatis-plus分页源码分析

  • 此项目gitee源代码:

    • git clone https://gitee.com/kelvin11/springboot-blank, 关于mybatis plus的源码在2框架demo/2.1mybatisplus这里
  • 目标:分析MyBatis-Plus源码是如何实现分页操作的

  • 步骤

    • 先进行基础代码的搭建
    • 分析一个查询分页源码走读

1. MyBatis-Plus分页功能实现

环境:

  • springboot:2.3.7.RELEASE

  • mybatis-plus-boot-starter:3.4.3

  • druid、mysql驱动

1.1. 主要的pom

<!-- 阿里druid数据库连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.8</version>
</dependency>
<!--druid需要,否则可能ClassNotFoundException: org.apache.log4j.Priority-->
<!--经过测试,druid配置改一下,'wall'用于防火墙,SpringBoot中没有log4j,我改成了log4j2,就不需要这个jar包了-->
<!--<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>-->

<!-- Mysql驱动包 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

<!--mybatis-plus jar包-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.3</version>
</dependency>

1.2. mapper

package com.example.mybatisplus.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mybatisplus.domain.MpGen;

/**
 * <p>
 * mybatis plus测试表 Mapper 接口
 * </p>
 *
 * @author KelvinLiu
 * @since 2021-11-26
 */
public interface MpGenMapper extends BaseMapper<MpGen> {

}

1.3. controller

package com.example.mybatisplus.controller;


import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.mybatisplus.domain.MpGen;
import com.example.mybatisplus.mapper.MpGenMapper;
import com.example.mybatisplus.service.IMpGenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 * mybatis plus测试表 前端控制器
 * </p>
 *
 * @author KelvinLiu
 * @since 2021-11-26
 */
@RestController
@RequestMapping("/mp-gen")
public class MpGenController {

    @Autowired
    IMpGenService mpGenService;

    /**
     * 测试枚举值的序列化。性别字段,在数据库是0和1。
     * https://baomidou.com/guide/enum.html
     *
     * [{"id":1,"name":"Jone","age":18,"gender":"女","email":"test1@baomidou.com","delFlag":0},
     * {"id":2,"name":"Jack","age":20,"gender":"男","email":"test2@baomidou.com","delFlag":0},
     * {"id":3,"name":"Tom","age":28,"gender":"男","email":"test3@baomidou.com","delFlag":0},
     * {"id":4,"name":"Sandy","age":21,"gender":"女","email":"test4@baomidou.com","delFlag":0},
     * {"id":5,"name":"Billie","age":24,"gender":"男","email":"updateToThis@123.com","delFlag":0},
     * {"id":7,"name":"kelvin","age":30,"gender":"男","email":"updateToThis@123.com","delFlag":0}]
     *
     * @return
     */
    @RequestMapping("/listall")
    public List<MpGen> listAll() {
        return mpGenService.list();
    }

    @Resource
    MpGenMapper mpGenMapper;

    /**
     * 分页源码分析入口
     * @return
     */
    @RequestMapping("/page")
    public List<MpGen> testPagination() {
        //参数一是当前页,参数二是每页个数
        IPage<MpGen> userPage = new Page<>(2, 2);
        return mpGenMapper.selectPage(userPage, null).getRecords();
    }

}

1.4. mybatis plus配置文件

这个是可有可无的,没有特别的要求,可以不配

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: del_flag  # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
#  configuration:
    # 是否将sql打印到控制面板(该配置会将sql语句和查询的结果都打印到控制台)
#    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  type-enums-package: com.example.mybatisplus.mybatisenums

logging:
  level:
    # mybatis plus使用debug级别,会输出sql语句
    com.example.mybatisplus.mapper: debug

1.5. MyBatis-Plus JavaConfig配置

package com.example.mybatisplus.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @ClassName MybatisPlusConfig
 * @Description
 * @Author liukun
 * @Date 2021/11/29 上午9:49
 */
@Configuration
public class MybatisPlusConfig {

    /**
     * 这个是分页的核心配置:
     * 1. 注册一个 MybatisPlusInterceptor(实现了org.apache.ibatis.plugin.Interceptor),所以mybatis在执行过程中会使用此拦截器
     * 2. MybatisPlusInterceptor内部持有一个拦截器列表,所以叫 xxxInnerInterceptor 。其中实现分页参数添加的拦截器是PaginationInnerInterceptor
     * @return
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        // 乐观锁
//        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
//        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }

    /*@Bean
    public OptimisticLockerInnerInterceptor optimisticLockerInterceptor() {
        return new OptimisticLockerInnerInterceptor();
    }*/

    /*@Bean
    public MybatisPlusInterceptor optimisticLockerInnerInterceptor(){
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }*/


}

2. MyBatis-Plus分页源码走读

2.1. 发起请求

这里顺便用到了一个好用的插件:RestfulToolkit-fix(jinhong 1.0.0),可以快速的复制完整的请求url:

拿到的就是:http://localhost:8021/mp-gen/page,放到浏览器头部请求进到断点。

2.2. mapper是个代理对象MybatisMapperProxy

mpGenMapper是个代理对象,是 MybatisMapperProxy

所以要进入到MybatisMapperProxyselectPage方法。debug进入selectPage方法调用,也就是MybatisMapperProxy.invoke方法,目的是为了找到实际调用的哪个类的哪个方法。

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            return Object.class.equals(method.getDeclaringClass()) ? method.invoke(this, args) : this.cachedInvoker(method).invoke(proxy, method, args, this.sqlSession);
        } catch (Throwable var5) {
            throw ExceptionUtil.unwrapThrowable(var5);
        }
    }

2.3. MybatisMapperProxy.MapperMethodInvoker

可以看到,应该是要调用interface com.baomidou.mybatisplus.core.mapper.BaseMapper.selectPage方法。但是这是一个接口,并不是实现,分析的目标应该看到this.cachedInvoker(method),看起来是找到此mapper接口的实现来进行selectPage方法的调用。

private MybatisMapperProxy.MapperMethodInvoker cachedInvoker(Method method) throws Throwable {
        try {
            return (MybatisMapperProxy.MapperMethodInvoker)CollectionUtils.computeIfAbsent(this.methodCache, method, (m) -> {
                if (m.isDefault()) {
                    try {
                        return privateLookupInMethod == null ? new MybatisMapperProxy.DefaultMethodInvoker(this.getMethodHandleJava8(method)) : new MybatisMapperProxy.DefaultMethodInvoker(this.getMethodHandleJava9(method));
                    } catch (InstantiationException | InvocationTargetException | NoSuchMethodException | IllegalAccessException var4) {
                        throw new RuntimeException(var4);
                    }
                } else {
                  // PlainMethodInvoker是cachedInvoker方法的返回类型,所以,this.cachedInvoker(method).invoke其实就是调用的PlainMethodInvoker类的invoke方法
                    return new MybatisMapperProxy.PlainMethodInvoker(new MybatisMapperMethod(this.mapperInterface, method, this.sqlSession.getConfiguration()));
                }
            });
        } catch (RuntimeException var4) {
            Throwable cause = var4.getCause();
            throw (Throwable)(cause == null ? var4 : cause);
        }
    }

根据上面代码的注释,继续找PlainMethodInvoker类的invoke方法:

private static class PlainMethodInvoker implements MybatisMapperProxy.MapperMethodInvoker {
        private final MybatisMapperMethod mapperMethod;

        public PlainMethodInvoker(MybatisMapperMethod mapperMethod) {
            this.mapperMethod = mapperMethod;
        }

        public Object invoke(Object proxy, Method method, Object[] args, SqlSession sqlSession) throws Throwable {
          // 核心是这里,断点到这里看看mapperMethod是哪个类
            return this.mapperMethod.execute(sqlSession, args);
        }
    }

2.4. MybatisMapperMethod.execute

所以,下面就是去找MybatisMapperMethod.execute()方法

public Object execute(SqlSession sqlSession, Object[] args) {
        Object result;
        Object param;
        switch(this.command.getType()) {
        case INSERT:
            param = this.method.convertArgsToSqlCommandParam(args);
            result = this.rowCountResult(sqlSession.insert(this.command.getName(), param));
            break;
        case UPDATE:
            param = this.method.convertArgsToSqlCommandParam(args);
            result = this.rowCountResult(sqlSession.update(this.command.getName(), param));
            break;
        case DELETE:
            param = this.method.convertArgsToSqlCommandParam(args);
            result = this.rowCountResult(sqlSession.delete(this.command.getName(), param));
            break;
        case SELECT:
            if (this.method.returnsVoid() && this.method.hasResultHandler()) {
                this.executeWithResultHandler(sqlSession, args);
                result = null;
            } else if (this.method.returnsMany()) {
                result = this.executeForMany(sqlSession, args);
            } else if (this.method.returnsMap()) {
                result = this.executeForMap(sqlSession, args);
            } else if (this.method.returnsCursor()) {
                result = this.executeForCursor(sqlSession, args);
            } else if (IPage.class.isAssignableFrom(this.method.getReturnType())) {
              // 这里比较坑,其实我们的mpGenMapper.selectPage()方法调用返回是IPage类型,下图是debug的结果。
              	// 继续分析this.executeForIPage(sqlSession, args)方法
                result = this.executeForIPage(sqlSession, args);
            } else {
                param = this.method.convertArgsToSqlCommandParam(args);
                result = sqlSession.selectOne(this.command.getName(), param);
                if (this.method.returnsOptional() && (result == null || !this.method.getReturnType().equals(result.getClass()))) {
                    result = Optional.ofNullable(result);
                }
            }
            break;
        case FLUSH:
            result = sqlSession.flushStatements();
            break;
        default:
            throw new BindingException("Unknown execution method for: " + this.command.getName());
        }

        if (result == null && this.method.getReturnType().isPrimitive() && !this.method.returnsVoid()) {
            throw new BindingException("Mapper method '" + this.command.getName() + " attempted to return null from a method with a primitive return type (" + this.method.getReturnType() + ").");
        } else {
            return result;
        }
    }

this.executeForIPage(sqlSession, args)分析:

private <E> Object executeForIPage(SqlSession sqlSession, Object[] args) {
        IPage<E> result = null;
        Object[] var4 = args;
        int var5 = args.length;

        for(int var6 = 0; var6 < var5; ++var6) {
            Object arg = var4[var6];
            if (arg instanceof IPage) {
                result = (IPage)arg;
                break;
            }
        }

        Assert.notNull(result, "can't found IPage for args!", new Object[0]);
        Object param = this.method.convertArgsToSqlCommandParam(args);
  			//debug看看下一步应该去哪儿? 
        List<E> list = sqlSession.selectList(this.command.getName(), param);
        result.setRecords(list);
        return result;
    }

2.5. SqlSessionTemplate.selectList

2.6. DefaultSqlSession.selectList

根据下图,下面应该是要去DefaultSqlSession.selectList方法,此时已经进入到mybatis的源码范围了:

到这里,终于快要接近事情的真相了:

2.7. Plugin.query

在DefaultSqlSession.selectList方法里,这个executor是Plugin类型

实际进入到了Plugin.invoke方法

需要看看Plugin里面是怎么实现的:

@Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      Set<Method> methods = signatureMap.get(method.getDeclaringClass());
      if (methods != null && methods.contains(method)) {
        // 看起来很美好,就是对interceptor进行了调用,这里,也就是调用了MybatisPlusInterceptor.interceptor方法
        return interceptor.intercept(new Invocation(target, method, args));
      }
      return method.invoke(target, args);
    } catch (Exception e) {
      throw ExceptionUtil.unwrapThrowable(e);
    }
  }

2.8. 对实现了mybatis inceptor的拦截器的调用(也就是MybatisPlusInterceptor)

下面跟进看看 MybatisPlusInterceptor.interceptor 方法:

public Object intercept(Invocation invocation) throws Throwable {
        Object target = invocation.getTarget();
        Object[] args = invocation.getArgs();
        if (target instanceof Executor) {
            final Executor executor = (Executor) target;
            Object parameter = args[1];
            boolean isUpdate = args.length == 2;
            MappedStatement ms = (MappedStatement) args[0];
            if (!isUpdate && ms.getSqlCommandType() == SqlCommandType.SELECT) {
                RowBounds rowBounds = (RowBounds) args[2];
                ResultHandler resultHandler = (ResultHandler) args[3];
                BoundSql boundSql;
                if (args.length == 4) {
                    boundSql = ms.getBoundSql(parameter);
                } else {
                    // 几乎不可能走进这里面,除非使用Executor的代理对象调用query[args[6]]
                    boundSql = (BoundSql) args[5];
                }
                for (InnerInterceptor query : interceptors) {
                  // 其实也可以联想到,我们的 JavaConfig里面配置的时候,就是add了一些interceptor,其中之一就是关于分页的,断点进来看看是不是如此?
                    if (!query.willDoQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql)) {
                        return Collections.emptyList();
                    }
                    query.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
                }
                CacheKey cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
                return executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
            } else if (isUpdate) {
                for (InnerInterceptor update : interceptors) {
                    if (!update.willDoUpdate(executor, ms, parameter)) {
                        return -1;
                    }
                    update.beforeUpdate(executor, ms, parameter);
                }
            }
        } else {
            // StatementHandler
            final StatementHandler sh = (StatementHandler) target;
            // 目前只有StatementHandler.getBoundSql方法args才为null
            if (null == args) {
                for (InnerInterceptor innerInterceptor : interceptors) {
                    innerInterceptor.beforeGetBoundSql(sh);
                }
            } else {
                Connection connections = (Connection) args[0];
                Integer transactionTimeout = (Integer) args[1];
                for (InnerInterceptor innerInterceptor : interceptors) {
                    innerInterceptor.beforePrepare(sh, connections, transactionTimeout);
                }
            }
        }
        return invocation.proceed();
    }

2.9. MybatisPlusIntercepotr中持有PaginationInnerInterceptor内部拦截器

2.10. PaginationInnerInterceptor.willDoQuery和beforeQuery

其实也可以看出,PaginationInnerInterceptor的核心方法,是willDoQuery和beforeQuery

willDoQuery是查询总数,来确定是否要进行分页查询,实际分页查询参数组装,应该是在beforeQuery方法中:

这里的page参数传递机制,是不是用的ThreadLocal?是如何把分页参数拼接到sql里的?待继续分析

2.11. 如何取出page分页信息?

从PaginationInnerInterceptor.willDoQuery里面,有找IPage的代码

public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
  			// 从parameter中找page参数,debug看一下目前parameter是什么类型、数据包
        IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
        if (page == null || page.getSize() < 0 || !page.searchCount()) {
            return true;
        }

        BoundSql countSql;
        MappedStatement countMs = buildCountMappedStatement(ms, page.countId());
        if (countMs != null) {
            countSql = countMs.getBoundSql(parameter);
        } else {
            countMs = buildAutoCountMappedStatement(ms);
            String countSqlStr = autoCountSql(page.optimizeCountSql(), boundSql.getSql());
            PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
            countSql = new BoundSql(countMs.getConfiguration(), countSqlStr, mpBoundSql.parameterMappings(), parameter);
            PluginUtils.setAdditionalParameter(countSql, mpBoundSql.additionalParameters());
        }

        CacheKey cacheKey = executor.createCacheKey(countMs, parameter, rowBounds, countSql);
        List<Object> result = executor.query(countMs, parameter, rowBounds, resultHandler, cacheKey, countSql);
        long total = 0;
        if (CollectionUtils.isNotEmpty(result)) {
            // 个别数据库 count 没数据不会返回 0
            Object o = result.get(0);
            if (o != null) {
                total = Long.parseLong(o.toString());
            }
        }
        page.setTotal(total);
        return continuePage(page);
    }

下面都是倒查参数的过程:

ParameterUtils.findPage(parameter)方法比较简单,就是过滤找IPage类型的分页对象

public static Optional<IPage> findPage(Object parameterObject) {
        if (parameterObject != null) {
            if (parameterObject instanceof Map) {
                Map<?, ?> parameterMap = (Map)parameterObject;
                Iterator var2 = parameterMap.entrySet().iterator();

                while(var2.hasNext()) {
                    Entry entry = (Entry)var2.next();
                    if (entry.getValue() != null && entry.getValue() instanceof IPage) {
                        return Optional.of((IPage)entry.getValue());
                    }
                }
            } else if (parameterObject instanceof IPage) {
                return Optional.of((IPage)parameterObject);
            }
        }

        return Optional.empty();
    }
}

下面就是要分析一下,parameter是什么时候包装并设置值的。要倒推来看。

com.baomidou.mybatisplus.core.override.MybatisMapperMethod#executeForIPage这个方法处,进行了参数的包装:

上一个调用者是com.baomidou.mybatisplus.core.override.MybatisMapperMethod#execute

再继续网上,就找到了com.baomidou.mybatisplus.core.override.MybatisMapperProxy#invoke

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
        // this.cachedInvoker(method)取出来的是MybatisMapperProxy.MapperMethodInvoker类型,调用其invoke方法,参数就是放在了args里
            return Object.class.equals(method.getDeclaringClass()) ? method.invoke(this, args) : this.cachedInvoker(method).invoke(proxy, method, args, this.sqlSession);
        } catch (Throwable var5) {
            throw ExceptionUtil.unwrapThrowable(var5);
        }
    }

也就回到了最开始使用mapper.selectPage调用的时候,参数就是这么传进来的。一路下来,没有看到使用ThreadLocal的地方。大多数的调用都是使用的代理。

这里就是有个存疑,mpGenMapper.selectPage(userPage, null).getRecords(),这里的mpGenMapper是怎么被注入为一个MybatisMapperProxy对象的?这个应该要另外从mybatis、springboot源码看起。

2.12. 如何将page分页信息绑定到sql中?

实际执行分页sql处理的,是在com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor#beforeQuery,如下图

这个方法不长,贴一下看看:

@Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
        if (null == page) {
            return;
        }

        // 处理 orderBy 拼接
        boolean addOrdered = false;
        String buildSql = boundSql.getSql();
        List<OrderItem> orders = page.orders();
        if (!CollectionUtils.isEmpty(orders)) {
            addOrdered = true;
            buildSql = this.concatOrderBy(buildSql, orders);
        }

        // size 小于 0 不构造分页sql
        if (page.getSize() < 0) {
            if (addOrdered) {
                PluginUtils.mpBoundSql(boundSql).sql(buildSql);
            }
            return;
        }

        handlerLimit(page);
        IDialect dialect = findIDialect(executor);

        final Configuration configuration = ms.getConfiguration();
      	// 分析的核心在这里,看方法名字就知道是构造分页sql,dialect这里肯定是mysqlDialect
        DialectModel model = dialect.buildPaginationSql(buildSql, page.offset(), page.getSize());
        PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);

      	// 这一块代码的作用是?
        List<ParameterMapping> mappings = mpBoundSql.parameterMappings();
        Map<String, Object> additionalParameter = mpBoundSql.additionalParameters();
        model.consumers(mappings, configuration, additionalParameter);
        mpBoundSql.sql(model.getDialectSql());
        mpBoundSql.parameterMappings(mappings);
    }

继续看一下MySqlDialect.buildPaginationSql()方法,也很简单:

public class MySqlDialect implements IDialect {

    @Override
    public DialectModel buildPaginationSql(String originalSql, long offset, long limit) {
        StringBuilder sql = new StringBuilder(originalSql).append(" LIMIT ").append(FIRST_MARK);
        if (offset != 0L) {
          	// 核心就是拼接 LIMIT,并且将分页的offset、limit传入到DialectModel构造函数中去。
          	// DialectModel也是baomidou的类,需要进一步分析是如何与mybatis产生交互的。
            sql.append(StringPool.COMMA).append(SECOND_MARK);
            return new DialectModel(sql.toString(), offset, limit).setConsumerChain();
        } else {
            return new DialectModel(sql.toString(), limit).setConsumer(true);
        }
    }
}

这一块代码,网上的解释还不多。

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-06-14 22:21:09  更:2022-06-14 22:23:11 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/23 19:15:28-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码