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知识库 -> Mybatis学习、使用和代码解析(一) -> 正文阅读

[Java知识库]Mybatis学习、使用和代码解析(一)

写在前面

说到mybatis,那可太熟悉了,几乎是所有的java开发从业者的必备框架之一。面试也非常喜欢的mybatis的内部实现和组成。我们在使用mybatis的时候往往都是配合SpringBoot一起。SpringBoot的自动配置会为我们日常的开发提供极大的便利,包括mybatis的配置,SpringBoot都为我们配置好了,我们只需要写写mapper就能优雅的请求到mysql的数据,而不需要走建立连接,配置sql,请求数据,处理结果等复杂流程。所以本文想尽可能的从最初的起点开始,看看mybatis是怎么实现整个流程的。

什么是mybatis

它是一款半自动的ORM持久层框架,具有较高的SQL灵活性,我们可以在配置中定制化sql代码,隐藏繁杂的数据连接请求过程,并且支持缓存、延迟加载等特性。

那什么是ORM呢,ORM指的是对象-关系映射(Object-Relational Mapping,简称ORM),可以描述为一个java对象与mysql中的一个表的映射,一个对象就像是表中的一条记录,对象的每个参数就与表中的每个字段保持一致。想象下如果不使用这种框架,每一次请求都需要重新处理返回值,校验等。代码重复率高且不便于维护。至于为什么叫半自动化,因为mybatis的sql是可以由我们自己控制的,那就可以控制返回的数据集,相比Hibernate更加灵活,Hibernate则是一种全自动的ORM框架,使用 Hibernate 查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取。

一个简单的实现-实例代码

我们看看mybatis是怎么使用的
项目结构
在这里插入图片描述

依赖配置

    <dependencies>

        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.4.4</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.16.8</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.19</version>
        </dependency>
    </dependencies>

User实体对象

package com.df.mybatis.dao.model;

import lombok.Data;

@Data
public class User {

    private Long id;

    private Long userId;

    private String userName;

    private Long tel;

    private Integer created;

    private Integer updated;

    private Integer isDeleted;
}

User的查询query

package com.df.mybatis.dao.query;

import lombok.Data;

@Data
public class UserQuery {

    private Long id;

    private Long userId;

}

UserMapper接口

package com.df.mybatis.dao.mapper;

import com.df.mybatis.dao.model.User;
import com.df.mybatis.dao.query.UserQuery;
import org.apache.ibatis.annotations.Param;

import java.io.IOException;
import java.util.List;

public interface UserMapper {

    List<User> selectByCondition(UserQuery userQuery) throws IOException;

}

用于sql映射的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">

<!--- 命名空间 namespace -->
<mapper namespace="com.df.mybatis.dao.mapper.UserMapper">

    <sql id="tableName">
        `User`
    </sql>


    <select id="selectByCondition" parameterType="com.df.mybatis.dao.query.UserQuery" resultType="com.df.mybatis.dao.model.User" >
        select * from
        <include refid="tableName"/>
        <where>
            <if test="id != null">AND `id` = #{id}</if>
             <if test="userId != null">AND `userId` = #{userId}</if>
        </where>
        limit 500
    </select>


</mapper>

mybatis配置 - 需要配置数据源,以及设置映射mapper

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>

    <!-- 环境配置:事务管理器和数据源配置 -->
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC" />
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3306/test?useSSL=false"/>
                <property name="username" value="root"/>
                <property name="password" value="root"/>
            </dataSource>
        </environment>
    </environments>

    <!-- 映射器 -->
    <mappers>
        <mapper resource="properties/mapper/UserMapper.xml" />
    </mappers>
</configuration>

UserMapper接口的实现

package com.df.mybatis.dao.Impl;

import com.df.mybatis.dao.mapper.UserMapper;
import com.df.mybatis.dao.model.User;
import com.df.mybatis.dao.query.UserQuery;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

public class UserMapperImpl implements UserMapper {


    /**
     * 根据条件查询用户信息
     *
     * @param userQuery 查询条件
     * @return 用户信息列表
     */
    @Override
    public List<User> selectByCondition(UserQuery userQuery) throws IOException {
        String resource = "properties/mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        List<User> userList = sqlSession.selectList("com.df.mybatis.dao.mapper.UserMapper.selectByCondition", userQuery);
        sqlSession.close();
        return userList;
    }
}

最后整个test运行一下

import com.df.mybatis.dao.Impl.UserMapperImpl;
import com.df.mybatis.dao.mapper.UserMapper;
import com.df.mybatis.dao.model.User;
import com.df.mybatis.dao.query.UserQuery;

import java.io.IOException;
import java.util.List;

public class Test {
    @org.junit.Test
    public void testMybatisSelect() throws IOException {
        UserMapper userMapper = new UserMapperImpl();
        UserQuery userQuery = new UserQuery();
        userQuery.setUserId(1L);
        List<User> users = userMapper.selectByCondition(userQuery);
        System.out.println(users);
    }
}

表结构

CREATE TABLE `User` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `userId` bigint unsigned NOT NULL DEFAULT '0' COMMENT '用户id',
  `userName` varchar(64) NOT NULL DEFAULT '' COMMENT '用户名',
  `age` int unsigned NOT NULL DEFAULT '0' COMMENT '年龄',
  `tel` bigint unsigned NOT NULL DEFAULT '0' COMMENT '手机号',
  `created` int unsigned NOT NULL DEFAULT '0' COMMENT '记录创建时间',
  `updated` int unsigned NOT NULL DEFAULT '0' COMMENT '更新时间',
  `isDeleted` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_userId` (`userId`)
) ENGINE=InnoDB AUTO_INCREMENT=117 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';

运行
在这里插入图片描述

代码分析

从结果上看,我们就是执行了一段sql,并得到了一条数据。学习的切入点可以从运行的test文件来看,我们new了一个UserMapperImpl对象,并执行了它的查询方法。在方法的内部,我们可以比较清晰的看到首先定义了我们mybatis配置文件的地址,并转换为了stream流的形式。

        String resource = "properties/mybatis-config.xml";
        // 读取配置文件成input流
        InputStream inputStream = Resources.getResourceAsStream(resource);

我们可以大致看看 Resources.getResourceAsStream(resource); 的内部是怎么解析mybatis配置文件的。

  public static InputStream getResourceAsStream(String resource) throws IOException {
    // 传入配置文件路径和对应需要的加载器
    return getResourceAsStream(null, resource);
  }
  //分隔符=======================流程分割
  // 这里传入的classLoader
  public InputStream getResourceAsStream(String resource, ClassLoader classLoader) {
  										//封装一下目前的各类类加载器数组
    return getResourceAsStream(resource, getClassLoaders(classLoader));
  }
    }
  //分隔符=======================流程分割
	  InputStream getResourceAsStream(String resource, ClassLoader[] classLoader) {
	 // 遍历一下可使用的类加载器
    for (ClassLoader cl : classLoader) {
      if (null != cl) {

        // 根据路径查找到xml文件并转换成流的格式
        InputStream returnValue = cl.getResourceAsStream(resource);

        // 如果找不到资源,,加上前缀"/"并重试
        if (null == returnValue) {
          returnValue = cl.getResourceAsStream("/" + resource);
        }
		// 如果成功获取到了资源,进行返回。如果获取不到就使用下一个类加载器继续尝试获取
        if (null != returnValue) {
          return returnValue;
        }
      }
    }
    return null;
  }

上述代码通过线程获取的类加载器得到了我们mybatis-config.xml的流数据。后会执行生成SqlSessionFactory的代码,那么SqlSessionFactory到底是什么呢?

SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

  //分隔符=======================流程分割
 public SqlSessionFactory build(InputStream inputStream) {
    return build(inputStream, null, null);
  }
  //分隔符=======================流程分割
    public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
    	// 根据上面得到的mybatis-config.xml的流数据得到xml配置文件对象。内部通过XPathParser生成,原流被解析成了一个Document(可以根据不同的父,获取子节点的数据,类似于树结构)。在解析流的同时也会注册一些配置项,如JdbcTransactionFactory,Slf4jImpl等。
      XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);	
      // 具体需要看一下parser.parse()方法
      return build(parser.parse());
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error building SqlSession.", e);
    } finally {
      ErrorContext.instance().reset();
      try {
        inputStream.close();
      } catch (IOException e) {
        // Intentionally ignore. Prefer previous error.
      }
    }
  }
  //分隔符=======================流程分割
    public Configuration parse() {
    if (parsed) {
      throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    parsed = true;
    //将XPathParser中的/configuration节点解析成Configuration对象。 configuration节点是不是比较熟悉,mybatis-config.xml的最外层就是一个configuration节点,从这里开始解析xml文件内部构造。
    parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
  }
  //分隔符=======================流程分割
  // 解析configuration节点内部节点,我们配置的configuration中有environments 和 mappers两个节点,我们重点就看这两个。其他节点如果没有配置,就不会进行解析。
  private void parseConfiguration(XNode root) {
    try {
      propertiesElement(root.evalNode("properties"));
      Properties settings = settingsAsProperties(root.evalNode("settings"));
      loadCustomVfs(settings);
      typeAliasesElement(root.evalNode("typeAliases"));
      pluginElement(root.evalNode("plugins"));
      objectFactoryElement(root.evalNode("objectFactory"));
      objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
      reflectorFactoryElement(root.evalNode("reflectorFactory"));
      settingsElement(settings);
      environmentsElement(root.evalNode("environments"));
      databaseIdProviderElement(root.evalNode("databaseIdProvider"));
      typeHandlerElement(root.evalNode("typeHandlers"));
      mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
  }
  //分隔符=======================流程分割
  // 解析environments节点
  private void environmentsElement(XNode context) throws Exception {
    if (context != null) {
      if (environment == null) {
       // 取到目前的默认配置环境
        environment = context.getStringAttribute("default");
      }
      // 遍历 解析每个environment
      for (XNode child : context.getChildren()) {
        // 取出environment的id属性
        String id = child.getStringAttribute("id");
          // 选择设置为default的environment
        if (isSpecifiedEnvironment(id)) {
        // 使用实例化Configuration时设置的typeAliasRegistry进行解析,生成对应type的对象TransactionFactory, 这里我们配置的JDBC,那么会去typeAliasRegistry中取出JDBC对应的JdbcTransactionFactory,反射生成对象。
          TransactionFactory txFactory = transactionManagerElement(child.evalNode("transactionManager"));
          // 同理,也会生成对应type的DataSourceFactory。我们的type设置为Pool,那么生成的应该是PooledDataSourceFactory。并把配置的url、驱动进行解析保存。
          DataSourceFactory dsFactory = dataSourceElement(child.evalNode("dataSource"));
          DataSource dataSource = dsFactory.getDataSource();
          // 将上面提取的对象进行保存,生成Environment对象
          Environment.Builder environmentBuilder = new Environment.Builder(id)
              .transactionFactory(txFactory)
              .dataSource(dataSource);
          // 配置在configuration中
          configuration.setEnvironment(environmentBuilder.build());
        }
      }
    }
  }

  //分隔符=======================流程分割 
  // 再来看看解析mapper的过程
  mapperElement(root.evalNode("mappers"));
	//进入解析流程
  private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
     	// 我们配置的mappers中的node是mapper
      for (XNode child : parent.getChildren()) {
       //所以这里不会进去
        if ("package".equals(child.getName())) {
          String mapperPackage = child.getStringAttribute("name");
          configuration.addMappers(mapperPackage);
        } else {
          //根据三种不同的方式获取配置文件
          String resource = child.getStringAttribute("resource");
          String url = child.getStringAttribute("url");
          String mapperClass = child.getStringAttribute("class");
          if (resource != null && url == null && mapperClass == null) {
            ErrorContext.instance().resource(resource);
            // 这里就跟最初通过路径获取文件流信息一样了,也是xml文件,所以获取的方式也一致
            InputStream inputStream = Resources.getResourceAsStream(resource);
            // xml配置建立,也是生成了一个XPathParser对象
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
            // 真正的解析过程在这里
            mapperParser.parse();
          } else if (resource == null && url != null && mapperClass == null) {
            ErrorContext.instance().resource(url);
            InputStream inputStream = Resources.getUrlAsStream(url);
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
            mapperParser.parse();
          } else if (resource == null && url == null && mapperClass != null) {
            Class<?> mapperInterface = Resources.classForName(mapperClass);
            configuration.addMapper(mapperInterface);
          } else {
            throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
          }
        }
      }
    }
  }
  //分隔符=======================流程分割 
  public void parse() {
  // 判断一下configuration中是否已经加载过该资源了 resource:properties/mapper/UserMapper.xml
    if (!configuration.isResourceLoaded(resource)) {
      // 开始解析UserMapper中的mapper节点,
      configurationElement(parser.evalNode("/mapper"));
      configuration.addLoadedResource(resource);
      // 绑定mapperxml文件与其接口的关系。并解析其接口,
      bindMapperForNamespace();
    }

    parsePendingResultMaps();
    parsePendingCacheRefs();
    parsePendingStatements();
  }
  //分隔符=======================流程分割 
   // 解析mapper节点的流程
    private void configurationElement(XNode context) {
    try {
      // 获取名称空间,也就是对应接口的全类名。com.df.mybatis.dao.mapper.UserMapper
      String namespace = context.getStringAttribute("namespace");
      // 校验一下名称空间是否是异常的
      if (namespace == null || namespace.equals("")) {
        throw new BuilderException("Mapper's namespace cannot be empty");
      }
      builderAssistant.setCurrentNamespace(namespace);
      // 到这里开始解析mapper路径下的各个配置
      cacheRefElement(context.evalNode("cache-ref"));
      cacheElement(context.evalNode("cache"));
      parameterMapElement(context.evalNodes("/mapper/parameterMap"));
      resultMapElements(context.evalNodes("/mapper/resultMap"));
      // 解析配置的sql标签,将key为mapper路径+id :node 存入sqlFragments的map
      sqlElement(context.evalNodes("/mapper/sql"));
      // 这个是我们必写的,也就是设置查询、增加修改删除的地方。
      // 内部解析标签内的属性,包括resultType这些,将属性整合处理成MappedStatement,以key:selectByCondition,value:MappedStatement的方式存到mappedStatements的map中
     buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing Mapper XML. Cause: " + e, e);
    }
  }
// 解析完毕以后回到最外层,SqlSessionFactory 就是对Configuration做了一层封装。 本质上就是Configuration
  public SqlSessionFactory build(Configuration config) {
    return new DefaultSqlSessionFactory(config);
  }
  public DefaultSqlSessionFactory(Configuration configuration) {
    this.configuration = configuration;
  }

解析得到SqlSessionFactory(Configuration)之后,下一步就是得到SqlSession,来看看获取SqlSession的过程又经历了什么。

SqlSession sqlSession = sqlSessionFactory.openSession();
  //分隔符=======================流程分割 
 public SqlSession openSession() {
    return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
  }
  //分隔符=======================流程分割 
  private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
    Transaction tx = null;
    try {
    // 获取在配置SqlSessionFactory时的一系列配置
      final Environment environment = configuration.getEnvironment();
      final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
	// 根据transactionFactory 得到我们配置的 JdbcTransaction。
      tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
      //根据jdbcTransaction 和 执行类型(SIMPLE) 得到执行执行器
      final Executor executor = configuration.newExecutor(tx, execType);
      // 将配置、执行器、是否自动提交(默认false)拼装生成DefaultSqlSession
      return new DefaultSqlSession(configuration, executor, autoCommit);
    } catch (Exception e) {
      closeTransaction(tx); // may have fetched a connection so lets call close()
      throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }
  // 配置Executor 包括事务,本地缓存等等
  protected BaseExecutor(Configuration configuration, Transaction transaction) {
    this.transaction = transaction;
    this.deferredLoads = new ConcurrentLinkedQueue<DeferredLoad>();
    this.localCache = new PerpetualCache("LocalCache");
    this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
    this.closed = false;
    this.configuration = configuration;
    this.wrapper = this;
  }

从上面的流程得知,其实sqlSession是对jdbcTransaction(配置的是jdbc类型)的一个封装,包括配置了sqlSession级的本地缓存,延迟队列等…具体还是要看一下是怎么使用sqlSession去执行sql语句的。

        List<User> userList = sqlSession.selectList("com.df.mybatis.dao.mapper.UserMapper.selectByCondition", userQuery);
  //分隔符=======================流程分割 
    @Override
  public <E> List<E> selectList(String statement, Object parameter) {
    return this.selectList(statement, parameter, RowBounds.DEFAULT);
  }
    //分隔符=======================流程分割 
  @Override
  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
    try {
    // 看到了熟悉的MappedStatements,以SelectId为key,MappedStatement为value的一个map。这里就通过id:com.df.mybatis.dao.mapper.UserMapper.selectByCondition获取到了对应的MappedStatement。
      MappedStatement ms = configuration.getMappedStatement(statement);
      // 通过executor执行。executor就是上面用transaction封装的对象
      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }
    //分隔符=======================流程分割 
  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
  	//得到可以执行的sql语句,并且会对#{}进行解析,将#{}替换成?的格式,并且对需要替换的参数进行获取。
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    // 执行查询方法
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }
  
    //分隔符=======================流程分割 
     // 在真正执行查询逻前还有一部分缓存获取等前置操作,直接跳转到查询的代码
       public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    Statement stmt = null;
    try {
      Configuration configuration = ms.getConfiguration();
      // 得到StatementHandler,handler内封装了需要执行的sql,参数配置、resultSet、mappedStatement等信息
      StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
      // 根据jdbcTransaction中的dataSource获取连接,并设置隔离级别,设置是否自动提交
      stmt = prepareStatement(handler, ms.getStatementLog());
    	// 执行查询
      return handler.<E>query(stmt, resultHandler);
    } finally {
      closeStatement(stmt);
    }
  }
    //分隔符=======================流程分割 
  @Override
  public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
    PreparedStatement ps = (PreparedStatement) statement;
    // 执行查询的过程
    ps.execute();
    // 处理结果
    return resultSetHandler.<E> handleResultSets(ps);
  }

整体看下来,就是对我们最古老写的代码的封装,本质还是通过PreparedStatement进行查询,只是不需要我们每次都配置这么底层的东西,方便我们的开发。
最后就是对sqlSession的关闭啦。

sqlSession.close();

结束

今天通过一个mybatis最简单的查询例子,简单的了解了一下内部的实现原理。当然在日常的开发中,会结合SpringBoot等开源框架使用,这些就等到下一次再解析吧~

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-01-04 13:16:53  更:2022-01-04 13:18:47 
 
开发: 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/24 9:05:14-

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