MyBatis缓存和二级缓存整合Redis
MyBatis缓存和二级缓存整合Redis
- ⼀级缓存
- 缓存验证
- 在⼀个sqlSession中,对user表根据username进⾏两次查询,查看他们发出sql语句的情况
- 同样是对user表进⾏两次查询,只不过两次查询之间进⾏了⼀次update操作。
- 总结
- ⼀级缓存原理探究与源码分析
- ⼆级缓存
- 开启⼆级缓存
- 测试
- 测试⼆级缓存和sqlSession无关
- 测试执⾏commit()操作,⼆级缓存数据清空
- useCache和flushCache
- ⼆级缓存整合Redis
- pom⽂件
- 配置⽂件Mapper.xml
- redis.properties
- 测试
- 源码分析
⼀级缓存
缓存验证
在⼀个sqlSession中,对user表根据username进⾏两次查询,查看他们发出sql语句的情况
@Testpublic void test1() throws IOException {InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);//根据 sqlSessionFactory 产⽣ sessionSqlSession sqlSession = sqlSessionFactory.openSession();UserMapper mapper = sqlSession.getMapper(UserMapper.class);//模拟条件userUser condition = new User();//condition.setId(1);condition.setUsername("zjq");//第⼀次查询,发出sql语句,并将查询出来的结果放进缓存中List<User> userList = mapper.findByCondition(condition);System.out.println("第一次查询结果:" + userList);//第⼆次查询,由于是同⼀个sqlSession,会在缓存中查询结果//如果有,则直接从缓存中取出来,不和数据库进⾏交互List<User> userList2 = mapper.findByCondition(condition);System.out.println("第二次查询结果:" + userList2);}
查看控制台打印情况:
看控制台输出可以看出来,第一次执行了SQL查询,第二次直接打印的结果集,没有查询数据库。
同样是对user表进⾏两次查询,只不过两次查询之间进⾏了⼀次update操作。
@Testpublic void test2() throws IOException {InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);//根据 sqlSessionFactory 产⽣ sessionSqlSession sqlSession = sqlSessionFactory.openSession();UserMapper mapper = sqlSession.getMapper(UserMapper.class);//模拟条件userUser condition = new User();//condition.setId(1);condition.setUsername("zjq");//第⼀次查询,发出sql语句,并将查询出来的结果放进缓存中List<User> userList = mapper.findByCondition(condition);System.out.println("第一次查询结果:" + userList);User user = new User();user.setId(3);user.setUsername("zjq666");user.setPassword("6566666666");//进⾏了⼀次更新操作,sqlSession.commit()mapper.updateById(user);sqlSession.commit();//第⼆次查询,由于是同⼀个sqlSession,会在缓存中查询结果//如果有,则直接从缓存中取出来,不和数据库进⾏交互List<User> userList2 = mapper.findByCondition(condition);System.out.println("第二次查询结果:" + userList2);sqlSession.close();}
查看控制台打印情况:
总结
- 第⼀次发起查询⽤户username为zjq的⽤户信息,先去找缓存中是否有username为zjq的⽤户信息,如果没有,从 数据库查询⽤户信息。得到⽤户信息,将⽤户信息存储到⼀级缓存中。
- 如果中间sqlSession去执⾏commit操作(执⾏插⼊、更新、删除),则会清空SqlSession中的一级缓存,这样做的⽬的为了让缓存中存储的是最新的信息,避免脏读。
- 第⼆次发起查询⽤户username为zjq的⽤户信息,先去找缓存中是否有username为zjq的⽤户信息,缓存中有,直接从缓存中获取⽤户信息。
⼀级缓存原理探究与源码分析
⼀级缓存到底是什么?⼀级缓存什么时候被创建、⼀级缓存的⼯作流程是怎样的?
上⾯我们⼀直提到⼀级缓存,那么提到⼀级缓存就绕不开SqlSession,所以索性我们就直接从SqlSession,看看有没有创建缓存或者与缓存有关的属性或者⽅法:
发现上述所有⽅法中,好像只有clearCache()和缓存沾点关系,那么就直接从这个⽅ 法⼊⼿吧,分析源码时,我们要看它(此类)是谁,它的⽗类和⼦类分别⼜是谁,对如上关系了解了,你才 会对这个类有更深的认识,分析了⼀圈,你可能会得到如下这个流程图:
public class PerpetualCache implements Cache { private Map<Object, Object> cache = new HashMap<Object, Object>(); @Overridepublic void clear() {cache.clear();}
}
再深⼊分析,流程⾛到Perpetualcache中的clear()⽅法之后,会调⽤其cache.clear()⽅法,那么这个cache是什么东⻄呢?点进去发现,cache其实就是private Map cache = new HashMap();也就是⼀个Map,所以说cache.clear()其实就是map.clear(),也就是说,缓存其实就是本地存放的⼀个map对象,每⼀个SqISession都会存放⼀个map对象的引⽤,那么这个cache是何时创建的呢?
你觉得最有可能创建缓存的地⽅是哪⾥呢?我觉得是Executor,为什么这么认为?因为Executor是执⾏器,⽤来执⾏SQL请求,⽽且清除缓存的⽅法也在Executor中执⾏,所以很可能缓存的创建也很有可能在Executor中,看了⼀圈发现Executor中有⼀个createCacheKey⽅法,这个⽅法很像是创建缓存的⽅法,跟进去看看,你发现createCacheKey⽅法是由BaseExecutor执⾏的,代码如下
CacheKey cacheKey = new CacheKey();
//MappedStatement 的 id
// id就是Sql语句的所在位置包名+类名+ SQL名称
cacheKey.update(ms.getId());
// offset 就是 0
cacheKey.update(rowBounds.getOffset());
// limit 就是 Integer.MAXVALUE
cacheKey.update(rowBounds.getLimit());
//具体的SQL语句
cacheKey.update(boundSql.getSql());
//后⾯是update 了 sql中带的参数
cacheKey.update(value);
...
if (configuration.getEnvironment() != null) {// issue #176
cacheKey.update(configuration.getEnvironment().getId());
}
创建缓存key会经过⼀系列的update⽅法,udate⽅法由⼀个CacheKey这个对象来执⾏的,这个
update⽅法最终由updateList的list来把五个值存进去,对照上⾯的代码和下⾯的图示,你应该能 理解
这五个值都是什么了。
这⾥需要注意⼀下最后⼀个值,configuration.getEnvironment().getId()这是什么,这其实就是 定义在
mybatis-config.xml中的标签,如下。
<environments default="development"><environment id="development"><transactionManager type="JDBC"/><dataSource type="POOLED"><property name="driver" value="${jdbc.driver}"/><property name="url" value="${jdbc.url}"/><property name="username" value="${jdbc.username}"/><property name="password" value="${jdbc.password}"/></dataSource></environment>
</environments>
**那么创建完缓存之后该⽤在何处呢?**总不会凭空创建⼀个缓存不使⽤吧?绝对不会的,经过我们对⼀级缓存的探究之后,我们发现⼀级缓存更多是⽤于查询操作,毕竟⼀级缓存也叫做查询缓存吧,为什么叫查询缓存我们⼀会⼉说。我们先来看⼀下这个缓存到底⽤在哪了,我们跟踪到org.apache.ibatis.executor.BaseExecutor的query⽅法,如下:
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds
rowBounds, ResultHandler resultHandler) throws SQLException {BoundSql boundSql = ms.getBoundSql(parameter);//创建缓存CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds
rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {...list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;if (list != null) {//这个主要是处理存储过程⽤的。handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key,boundSql);
}
...
}
// queryFromDatabase ⽅法
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter,
RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql
boundSql) throws SQLException {List<E> list;localCache.putObject(key, EXECUTION_PLACEHOLDER);try {list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);} finally {localCache.removeObject(key);}localCache.putObject(key, list);if (ms.getStatementType() == StatementType.CALLABLE) {localOutputParameterCache.putObject(key, parameter);}return list;
}
如果查不到的话,就从数据库查,在queryFromDatabase中,会对localCache进⾏写⼊。 localCache对象的put⽅法最终交给Map进⾏存放。
⼆级缓存
⼆级缓存的原理和⼀级缓存原理⼀样,第⼀次查询,会将数据放⼊缓存中,然后第⼆次查询则会直接去缓存中取。但是⼀级缓存是基于sqlSession的,⽽⼆级缓存是基于mapper⽂件的namespace的,也就是说多个sqlSession可以共享⼀个mapper中的⼆级缓存区域,并且如果两个mapper的namespace 相同,即使是两个mapper,那么这两个mapper中执⾏sql查询到的数据也将存在相同的⼆级缓存区域中
如何使用二级缓存
开启⼆级缓存
和⼀级缓存默认开启不⼀样,⼆级缓存需要我们⼿动开启
⾸先在全局配置⽂件sqlMapConfig.xml⽂件中加⼊如下代码:
<!--开启⼆级缓存-->
<settings><setting name="cacheEnabled" value="true"/>
</settings>
其次在UserMapper.xml⽂件中开启缓存
<!--开启⼆级缓存-->
<cache></cache>
我们可以看到mapper.xml⽂件中就这么⼀个空标签,其实这⾥可以配置,PerpetualCache这个类是mybatis默认实现缓存功能的类。我们不写type就使⽤mybatis默认的缓存,也可以去实现Cache接⼝来⾃定义缓存。
public class PerpetualCache implements Cache {private final String id;private MapcObject, Object> cache = new HashMap();public PerpetualCache(String id) {this.id = id;}
}
public class User implements Serializable(//⽤户IDprivate int id;//⽤户姓名private String username;//⽤户性别private String sex;
}
开启了⼆级缓存后,还需要将要缓存的pojo实现Serializable接⼝,为了将缓存数据取出执⾏反序列化操
作,因为⼆级缓存数据存储介质多种多样,不⼀定只存在内存中,有可能存在硬盘中,如果我们要再取
这个缓存的话,就需要反序列化了。所以mybatis中的pojo都去实现Serializable接⼝。
测试
测试⼆级缓存和sqlSession无关
@Test
public void testTwoCache(){//根据 sqlSessionFactory 产⽣ sessionSqlSession sqlSession1 = sessionFactory.openSession();SqlSession sqlSession2 = sessionFactory.openSession();UserMapper userMapper1 = sqlSession1.getMapper(UserMapper. class );UserMapper userMapper2 = sqlSession2.getMapper(UserMapper. class );//第⼀次查询,发出sql语句,并将查询的结果放⼊缓存中User u1 = userMapper1.selectUserByUserId(1);System.out.println(u1);sqlSession1.close(); //第⼀次查询完后关闭 sqlSession//第⼆次查询,即使sqlSession1已经关闭了,这次查询依然不发出sql语句User u2 = userMapper2.selectUserByUserId(1);System.out.println(u2);sqlSession2.close();
}
可以看出上⾯两个不同的sqlSession,第⼀个关闭了,第⼆次查询依然不发出sql查询语句。
测试执⾏commit()操作,⼆级缓存数据清空
@Test
public void testTwoCache(){//根据 sqlSessionFactory 产⽣ sessionSqlSession sqlSession1 = sessionFactory.openSession();SqlSession sqlSession2 = sessionFactory.openSession();SqlSession sqlSession3 = sessionFactory.openSession();UserMapper userMapper1 = sqlSession1.getMapper(UserMapper. class );UserMapper userMapper2 = sqlSession2.getMapper(UserMapper. class );UserMapper userMapper3 = sqlSession2.getMapper(UserMapper. class );//第⼀次查询,发出sql语句,并将查询的结果放⼊缓存中User u1 = userMapperl.selectUserByUserId( 1 );System.out.println(u1);sqlSessionl .close(); //第⼀次查询完后关闭sqlSession//执⾏更新操作,commit()u1.setUsername( "zjq" );userMapper3.updateUserByUserId(u1);sqlSession3.commit();//第⼆次查询,由于上次更新操作,缓存数据已经清空(防⽌数据脏读),这⾥必须再次发出sql语句User u2 = userMapper2.selectUserByUserId( 1 );System.out.println(u2);sqlSession2.close();
}
useCache和flushCache
mybatis中还可以配置userCache和flushCache等配置项,userCache是⽤来设置是否禁⽤⼆级缓存的,在statement中设置useCache=false可以禁⽤当前select语句的⼆级缓存,即每次查询都会发出 sql去查询,默认情况是true,即该sql使⽤⼆级缓存。
<select id="selectUserByUserId" useCache="false" resultType="com.zjq.pojo.User" parameterType="int">select * from user where id=#{id}
</select>
这种情况是针对每次查询都需要最新的数据sql,要设置成useCache=false,禁⽤⼆级缓存,直接从数 据库中获取。
在mapper的同⼀个namespace中,如果有其它insert、update, delete操作数据后需要刷新缓 存,如果不执⾏刷新缓存会出现脏读。
设置statement配置中的flushCache="true”属性,默认情况下为true,即刷新缓存,如果改成false则 不会刷新。使⽤缓存时如果⼿动修改数据库表中的查询数据会出现脏读。
<select id="selectUserByUserId" flushCache="true" useCache="false"
resultType="com.zjq.pojo.User" parameterType="int">select * from user where id=#{id}
</select>
⼀般下执⾏完commit操作都需要刷新缓存,flushCache=true表示刷新缓存,这样可以避免数据库脏读。所以我们不⽤设置,默认即可。
⼆级缓存整合Redis
上⾯我们介绍了 mybatis⾃带的⼆级缓存,但是这个缓存是单服务器⼯作,⽆法实现分布式缓存。 那么
什么是分布式缓存呢?假设现在有两个服务器1和2,⽤户访问的时候访问了 1服务器,查询后的缓 存就
会放在1服务器上,假设现在有个⽤户访问的是2服务器,那么他在2服务器上就⽆法获取刚刚那个 缓
存,为了解决这个问题,就得找⼀个分布式的缓存,专⻔⽤来存储缓存数据的,这样不同的服务器要缓存数
据都往它那⾥存,取缓存数据也从它那⾥取,如下图所示:
如上图所示,在⼏个不同的服务器之间,我们使⽤第三⽅缓存框架,将缓存都放在这个第三⽅框架中,
然后⽆论有多少台服务器,我们都能从缓存中获取数据。
这⾥我们介绍mybatis与redis的整合。
刚刚提到过,mybatis提供了⼀个eache接⼝,如果要实现⾃⼰的缓存逻辑,实现cache接⼝开发即可。
mybati s本身默认实现了⼀个,但是这个缓存的实现⽆法实现分布式缓存,所以我们要⾃⼰来实现。
redis分布式缓存就可以,mybatis提供了⼀个针对cache接⼝的redis实现类,该类存在mybatis-redis包
中实现:
pom⽂件
<dependency><groupId>org.mybatis.caches</groupId><artifactId>mybatis-redis</artifactId><version>1.0.0-beta2</version>
</dependency>
配置⽂件Mapper.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.zjq.mapper.IUserMapper"><cache type="org.mybatis.caches.redis.RedisCache" /><select id="findAll" resultType="com.zjq.pojo.User" useCache="true">select * from user</select>
redis.properties
redis.host=localhost
redis.port=6379
redis.connectionTimeout=5000
redis.password=
redis.database=0
测试
@Test
public void SecondLevelCache(){SqlSession sqlSession1 = sqlSessionFactory.openSession();SqlSession sqlSession2 = sqlSessionFactory.openSession();SqlSession sqlSession3 = sqlSessionFactory.openSession();IUserMapper mapper1 = sqlSession1.getMapper(IUserMapper.class);lUserMapper mapper2 = sqlSession2.getMapper(lUserMapper.class);lUserMapper mapper3 = sqlSession3.getMapper(IUserMapper.class);User user1 = mapper1.findUserById(1);sqlSession1.close(); //清空⼀级缓存User user = new User();user.setId(1);user.setUsername("lisi");mapper3.updateUser(user);sqlSession3.commit();User user2 = mapper2.findUserById(1);System.out.println(user1==user2);
}
源码分析
RedisCache和⼤家普遍实现Mybatis的缓存⽅案⼤同⼩异,⽆⾮是实现Cache接⼝,并使⽤jedis操作缓存;不过该项⽬在设计细节上有⼀些区别;
public final class RedisCache implements Cache {public RedisCache(final String id) {if (id == null) {throw new IllegalArgumentException("Cache instances require anID");}this.id = id;RedisConfig redisConfig =RedisConfigurationBuilder.getInstance().parseConfiguration();pool = new JedisPool(redisConfig, redisConfig.getHost(),redisConfig.getPort(),redisConfig.getConnectionTimeout(),redisConfig.getSoTimeout(), redisConfig.getPassword(),redisConfig.getDatabase(), redisConfig.getClientName());}
}
RedisCache在mybatis启动的时候,由MyBatis的CacheBuilder创建,创建的⽅式很简单,就是调⽤RedisCache的带有String参数的构造⽅法,即RedisCache(String id);⽽在RedisCache的构造⽅法中,调⽤了 RedisConfigu rationBuilder 来创建 RedisConfig 对象,并使⽤ RedisConfig 来创建JedisPool。RedisConfig类继承了JedisPoolConfig,并提供了 host,port等属性的包装,简单看⼀下RedisConfig的属性:
public class RedisConfig extends JedisPoolConfig {private String host = Protocol.DEFAULT_HOST;private int port = Protocol.DEFAULT_PORT;private int connectionTimeout = Protocol.DEFAULT_TIMEOUT;private int soTimeout = Protocol.DEFAULT_TIMEOUT;private String password;private int database = Protocol.DEFAULT_DATABASE;private String clientName;
RedisConfig对象是由RedisConfigurationBuilder创建的,简单看下这个类的主要⽅法:
public RedisConfig parseConfiguration(ClassLoader classLoader) {Properties config = new Properties();InputStream input =classLoader.getResourceAsStream(redisPropertiesFilename);if (input != null) {try {config.load(input);} catch (IOException e) {throw new RuntimeException("An error occurred while reading classpath property '"+ redisPropertiesFilename+ "', see nested exceptions", e);} finally {try {input.close();} catch (IOException e) {// close quietly}}}RedisConfig jedisConfig = new RedisConfig();setConfigProperties(config, jedisConfig);return jedisConfig;
}
核⼼的⽅法就是parseConfiguration⽅法,该⽅法从classpath中读取⼀个redis.properties⽂件:
host=localhost
port=6379
connectionTimeout=5000
soTimeout=5000
password=
database=0
clientName=
并将该配置⽂件中的内容设置到RedisConfig对象中,并返回;接下来,就是RedisCache使⽤RedisConfig类创建完成redisPool;在RedisCache中实现了⼀个简单的模板⽅法,⽤来操作Redis:
private Object execute(RedisCallback callback) {Jedis jedis = pool.getResource();try {return callback.doWithRedis(jedis);} finally {jedis.close();}
}
模板接⼝为RedisCallback,这个接⼝中就只需要实现了⼀个doWithRedis⽅法⽽已:
public interface RedisCallback {Object doWithRedis(Jedis jedis);
}
接下来看看Cache中最重要的两个⽅法:putObject和getObject,通过这两个⽅法来查看mybatis-redis储存数据的格式:
@Override
public void putObject(final Object key, final Object value) {execute(new RedisCallback() {@Overridepublic Object doWithRedis(Jedis jedis) {jedis.hset(id.toString().getBytes(), key.toString().getBytes(),SerializeUtil.serialize(value));return null;}});
}@Override
public Object getObject(final Object key) {return execute(new RedisCallback() {@Overridepublic Object doWithRedis(Jedis jedis) {return SerializeUtil.unserialize(jedis.hget(id.toString().getBytes(),key.toString().getBytes()));}});
}
可以很清楚的看到,mybatis-redis在存储数据的时候,是使⽤的hash结构,把cache的id作为这个hash的key (cache的id在mybatis中就是mapper的namespace);这个mapper中的查询缓存数据作为 hash的field,需要缓存的内容直接使⽤SerializeUtil存储,SerializeUtil和其他的序列化类差不多,负责对象的序列化和反序列化;
本文内容到此结束了,
如有收获欢迎点赞MyBatis缓存和二级缓存整合Redis相关推荐
- Mybatis | Mybatis 一级缓存、二级缓存、三级自定义缓存(Redis)
Mybatis 一级缓存.二级缓存.三级自定义缓存Redis实现 一.Mybatis 缓存 二.一级缓存 SqlSession级别 1.如何开启一级缓存 2.什么时候清除缓存? 3.什么时候缓存失效? ...
- mybatis一级缓存、二级缓存以及集成EnCache、Redis,避免脏读
参考书目:<mybatis从入门到精通> 刘增辉 著 作者GitHub:https://github.com/abel533/MyBatis-Spring-Boot 一级缓存 mybat ...
- MyBatis→优缺点、select延迟加载、接口式MyBatis编程、一级缓存、二级缓存、集成Redis自定义缓存、Log4j
MyBatis优缺点 select延迟加载 接口式MyBatis编程 一级缓存 一级缓存原理 一级缓存命中原则 一级缓存销毁 一级缓存避免脏读不可重复读 一级缓存与spring@事务 二级缓存 与一级 ...
- Mybatis一级缓存,二级缓存的实现就是这么简单
介绍 又到了一年面试季,所以打算写一点面试常问的东西,争取说的通俗易懂.面试高级岗,如果你说熟悉Mybatis,下面这些问题基本上都会问 Mybatis插件的实现原理? 如何写一个分页插件? Myba ...
- mybatis中一级缓存和二级缓存
1.一级缓存 一级缓存是SqlSession级别的缓存.在操作数据库时需要构造 sqlSession对象,在对象中有一个(内存区域)数据结构(HashMap)用于存储缓存数据.不同的sqlSessio ...
- 研究一天,终于把MyBatis的一级缓存和二级缓存搞清楚了
介绍 MyBatis 现在是面试必问的,其中最主要的除了一些启动流程,基础语法,那么就是缓存问题了. 大家都知道 MyBatis 是有二级缓存,底层都是用HashMap实现的,key为CacheKey ...
- 【MyBatis学习13】MyBatis中的二级缓存
1. 二级缓存的原理 前面介绍了,mybatis中的二级缓存是mapper级别的缓存,值得注意的是,不同的mapper都有一个二级缓存,也就是说,不同的mapper之间的二级缓存是互不影响的.为了更加 ...
- 项目整合一级缓存和二级缓存
Redis+ehCache实现两级缓存 spring boot中集成了spring cache,并有多种缓存方式的实现,如:Redis.Caffeine.JCache.EhCache等等.但如果只用一 ...
- MyBatis】MyBatis一级缓存和二级缓存
转载自 MyBatis]MyBatis一级缓存和二级缓存 MyBatis自带的缓存有一级缓存和二级缓存 一级缓存 Mybatis的一级缓存是指Session缓存.一级缓存的作用域默认是一个SqlSe ...
最新文章
- 机器学习中的三对性能度量参数
- Java Web知识梳理
- DBMS_STATS.GATHER_TABLE_STATS详解
- LC.exe”已退出,代码为 -1
- 3.1、Eclipse
- asp.net core根据用户权限控制页面元素的显示
- Windows下FFmpeg各版本库文件下载
- 嵌入式应用程序下载到ARM开发板后如何运行程序?
- android 微信文件存储,安卓微信文件存储位置
- 清华师哥封神之作!RocketMQ核心笔记疯传Ali内网
- gradle系列-1-gradle -x test clean build
- Spring-IoC注解
- 概率论与数理统计 基本概念
- EFR32 资源汇总
- 使用ExcelJs导出表格设置样式、添加边框
- VC的静态链接库 动态链接库
- 快速掌握SAP BDC数据导入
- 速营社团队分享互联网发展过程那些疯狂的时代
- 信息学竞赛中可能用到的初等数论
- 为搬砖做一点准备工作
热门文章