用了挺久的mybatis,但一直停留在用的层面上,觉得不行的呀,得走出舒适区。
所以想自己看看mybatis的实现,然后模仿着写一个,哈哈,当然一开始不会要求完成度很高。
这一篇就先看下mybatis奥秘。

这里参考的mybatis源码版本是3.4.5。

首先,先写一个mybatis简单使用的例子。

// 使用
public static void main(String[] args) throws IOException {//根据配置文件创建一个SqlSessionFactory对象String resource = "mybatis-config.xml";InputStream inputStream = Resources.getResourceAsStream(resource);SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);// 获取sqlSession对象SqlSession session = sqlSessionFactory.openSession();try{// 获取接口的实现类实例IUserMapper mapper = session.getMapper(IUserMapper.class);// 调用方法User user = mapper.findById(1);System.out.println(user.getName());}finally{session.close();}
}            

回忆一下,使用Mybatis的步骤就是

  1. 写配置文件,配置连接数据库的参数,mybatis的参数。
  2. 定义接口,并且通过注解或者xml文件的形式提供SQL语句。之后要在配置文件中注册这个接口。
  3. 创建SqlSessionFactory,传入配置文件。通过工厂获得SqlSession对象。
  4. 通过SqlSession对象获取自定义的接口的实例,然后就是调用接口的方法。

整个过程中,玩家就只参与了配置参数,还有提供SQL这两步。所以这两步就是看mybatis怎么操作的入口,是进入mybatis地下城的大门。
配置参数这部分,使用框架时基本都有这个操作,比较常见。所以算是个分支剧情,而提供SQL算是mybatis的主线剧情,这里先通关主线剧情。


剧情1 之 发生了什么

IUserMapper mapper = session.getMapper(IUserMapper.class);
User user = mapper.findById(1);

可以看到,在使用时,我们获取到了我们的接口的一个实现类实例,
燃鹅,我们没有写这个接口的实现的呀。所以我觉得是魔法的原因,在这里要打个断点。

  1. 在getMapper的方法上断点,我们进入了DefaultSqlSession.getMapper(Class<T>),
    所以默认我们从SqlSessionFactory拿到的是一个DefaultSqlSession的实例。

    /*
    通过configuration的getMapper方法,传入我们的接口类型以及SqlSession实例,返
    回一个泛型。这里也就是我们的IUserMapper接口的实现类的实例。*/
    @Override
    public <T> T getMapper(Class<T> type) {return configuration.<T>getMapper(type, this);
    }
    
  2. 再进去是Configuration.getMapper(Class<T>, SqlSession)。

    /*
    这里又从mapperRegistry里拿到对象,
    mapperRegistry是Configuration类的一个属性*/
    public <T> T getMapper(Class<T> type, SqlSession sqlSession) {return mapperRegistry.getMapper(type, sqlSession);
    }
  3. 看一下MapperRegistry的getMapper里边是什么。
    这里看到了令人激动的字眼,就是Proxy,
    猜测我们最终拿到的IUserMapper的实例是个代理对象

    @SuppressWarnings("unchecked")
    public <T> T getMapper(Class<T> type, SqlSession sqlSession) {// 看了下,knownMappers是个Map对象,Map<Class<?>, MapperProxyFactory<?>>final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);if (mapperProxyFactory == null) {throw new BindingException("Type " + type + " is not known to the MapperRegistry.");}try {/*新建一个实例,需要进去看下*/return mapperProxyFactory.newInstance(sqlSession);} catch (Exception e) {throw new BindingException("Error getting mapper instance. Cause: " + e, e);}
    }
  4. MapperProxyFactory的newInstance 一探究竟
    其实名字叫xxFactory的肯定是生产xx的,可以猜到返回的是个MapperProxy

    public T newInstance(SqlSession sqlSession) {/*这里new了一个MapperProxy,然后调用newInstance*/final MapperProxy<T> mapperProxy = new MapperProxy<T>(sqlSession, mapperInterface, methodCache);return newInstance(mapperProxy);
    }
  5. MapperProxy是个啥

    /* 这个类实现了InvocationHandler,动态代理的接口。*/
    public class MapperProxy<T> implements InvocationHandler, Serializable 
  6. 看看newInstance(mapperProxy)做了啥。
    使用Proxy构造实现我们IUserMapper接口的代理类的实例!

    @SuppressWarnings("unchecked")
    protected T newInstance(MapperProxy<T> mapperProxy) {return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy);
    }         
  7. 消化一下,开始的疑问是我们没有提供IUserMapper的实现,但是通过SqlSession的getMapper方法能拿到一个IUserMapper的实现类的对象。
    谜底就是最终返回了我们接口的一个代理类的实例。
    而MapperProxy实现了InvocationHandler接口,在我们构造代理对象时传入了MapperProxy对象,
    因此在调IUserMapper的所有方法时,都会进入到MapperProxy类的invoke方法。
    其实不像上边那样操作,通过直接打印这个对象也可以看出来..

    System.out.println(mapper);
    System.out.println(Proxy.isProxyClass(mapper.getClass()));
    // 打印结果,贴图片太丑了,就不贴结果图了。
    org.apache.ibatis.binding.MapperProxy@e580929
    true  

剧情2 之 MapperProxy你干了啥

一般使用动态代理,实现了InvocationHandler接口的类中都会持有被代理类的引用,这里也就是MapperProxy。然后在invoke方法里边先执行额外的操作,再调用被代理类的方法。在MapperProxy这个类里却没找到被代理类的引用。

public class MapperProxy<T> implements InvocationHandler, Serializable {private static final long serialVersionUID = -6424540398559729838L;private final SqlSession sqlSession;private final Class<T> mapperInterface;private final Map<Method, MapperMethod> methodCache;。。。
}

所以穿山甲说了什么?
所以当我们调用 IUserMapper 的 findById 时发生了什么?
这里就要看下MapperProxy的invoke方法了。

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {try {if (Object.class.equals(method.getDeclaringClass())) {return method.invoke(this, args);} else if (isDefaultMethod(method)) {return invokeDefaultMethod(proxy, method, args);}} catch (Throwable t) {throw ExceptionUtil.unwrapThrowable(t);}final MapperMethod mapperMethod = cachedMapperMethod(method);return mapperMethod.execute(sqlSession, args);
}
  1. 首先进行一个 if 判断,逻辑是 如果调的这个方法的提供类是Object类,那个就直接执行这个方法。
    这里容易想偏,哪个类不是Object的子类呀..
    其实应该是 如果是Object中的方法,那就直接执行。
    Object有哪些方法呢?toString这些。调mapper.toString()时,就直接被执行,不走下边的逻辑了。

    if (Object.class.equals(method.getDeclaringClass())) {return method.invoke(this, args);
    }
    
  2. 之后是第二个 if,逻辑是,如果这个方法的权限修饰符是public并且是由接口提供的,则执行invokeDefaultMethod方法。
    比如在IUserMapper写了一个默认方法,执行这个方法isDefaultMethod就会返回true了。
    这里我们的方法的提供方是代理类,不是接口,所以返回了false。

    else if (isDefaultMethod(method)) {return invokeDefaultMethod(proxy, method, args);
    }// isDefaultMethod
    private boolean isDefaultMethod(Method method) {
    return ((method.getModifiers() & (Modifier.ABSTRACT | Modifier.PUBLIC | Modifier.STATIC)) == Modifier.PUBLIC)&& method.getDeclaringClass().isInterface();
    }
    
  3. 前两步都是过滤作用,下边的才是重点。
    可以看到通过 cachedMapperMethod方法 拿到了一个 MapperMethod 对象。
    看名字是从缓存里拿。然后就执行MapperMethod的execute方法。

    final MapperMethod mapperMethod = cachedMapperMethod(method);
    return mapperMethod.execute(sqlSession, args);
    
  4. 缓一缓,小结一下。开始的疑问是,MapperProxy类里边竟然没有被代理类对象的引用。
    那它想干什么。在invoke方法中我们找到答案。
    通过invoke方法的method参数,拿到了一个MapperMethod 对象,
    然后执行了这个对象的execute方法,就没了。中间一些常规的方法就直接执行。
    所以纯粹就是为了进入invoke方法,拿到MapperMethod ,至始至终都不存在被代理类。
    哇,代理的神奇用法,小本本记起来。
  5. 接着我们看看怎么通过method参数拿到MapperMethod
    这里就很简单了,Map里边有就直接返回,没有就新建。接口的一个方法就对应一个MapperMethod。
    so easy ~

    //cachedMapperMethod
    private MapperMethod cachedMapperMethod(Method method) {// methodCache 是个Map<Method, MapperMethod>MapperMethod mapperMethod = methodCache.get(method);if (mapperMethod == null) {mapperMethod = new MapperMethod(mapperInterface, method, sqlSession.getConfiguration());methodCache.put(method, mapperMethod);}return mapperMethod;
    }
  6. 看下MapperMethod的构造过程,发现传入了接口信息,方法信息,还有配置信息。
    主要工作是初始化 command 还有 method 字段。
    command里边就保存方法的名称(com.mapper.IUserMapper.findById),还有对应的SQL类型(SELECT)。
    method里边保存了方法的返回类型,是否是集合,是否是游标等信息。
    看到这里,其实我一直在忽略Configuration这个类里边是什么东西,等要模仿再去看。

    public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {this.command = new SqlCommand(config, mapperInterface, method);this.method = new MethodSignature(config, mapperInterface, method);
    }

剧情3 之 MapperMethod你跟着干了啥

我们调用 mapper.findByid, 最终是通过MapperMethod执行execute得到结果。
所以接下来要看看execute方法中隐藏了什么秘密。

  1. 下边是execute方法的内容

    public Object execute(SqlSession sqlSession, Object[] args) {Object result;switch (command.getType()) {case INSERT: {Object param = method.convertArgsToSqlCommandParam(args);result = rowCountResult(sqlSession.insert(command.getName(), param));break;}case UPDATE: {Object param = method.convertArgsToSqlCommandParam(args);result = rowCountResult(sqlSession.update(command.getName(), param));break;}case DELETE: {Object param = method.convertArgsToSqlCommandParam(args);result = rowCountResult(sqlSession.delete(command.getName(), param));break;}case SELECT:if (method.returnsVoid() && method.hasResultHandler()) {executeWithResultHandler(sqlSession, args);result = null;} else if (method.returnsMany()) {result = executeForMany(sqlSession, args);} else if (method.returnsMap()) {result = executeForMap(sqlSession, args);} else if (method.returnsCursor()) {result = executeForCursor(sqlSession, args);} else {Object param = method.convertArgsToSqlCommandParam(args);result = sqlSession.selectOne(command.getName(), param);}break;case FLUSH:result = sqlSession.flushStatements();break;default:throw new BindingException("Unknown execution method for: " + command.getName());}if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {   throw new BindingException("Mapper method '" + command.getName() + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");}return result;
    }
  2. 可以看到 INSERT UPDATE 这些熟悉的字眼的了。
    通过MapperMethod里的command的属性,进入不同分支。
    这里调用的是findById,进入了SELECT分支,最终执行了下边的语句,第一句是装配参数,第二句是执行查询。

    Object param = method.convertArgsToSqlCommandParam(args);
    result = sqlSession.selectOne(command.getName(), param);
  3. 看下 sqlSession.selectOne(),里边调用了selectList的方法,然后将结果返回。

    @Override
    public <T> T selectOne(String statement, Object parameter) {// Popular vote was to return null on 0 results and throw exception on too many.List<T> list = this.<T>selectList(statement, parameter);if (list.size() == 1) {return list.get(0);} else if (list.size() > 1) {throw new TooManyResultsException("Expected one result (or null) to be returned by selectOne(), but found: " + list.size());} else {return null;}
    }
  4. 进入到selectList瞧瞧,感觉流程要走完了,都已经开始select了。
    这里又看到了令人激动的字眼,statement。感觉已经在靠近JDBC啦。
    有个MappedStatement的对象需要关注一下。

    @Override
    public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {try {MappedStatement ms = configuration.getMappedStatement(statement);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();}
    }
  5. 回忆下基本JDBC是怎么用的。

    /*1 加载对应数据库驱动Class.forName(driverClass);   2 获取数据库连接Connection con = DriverManager.getConnection(jdbcUrl, user, password);3 准备SQL语句String sql = " ... ";4 执行操作Statement statement = con.createStatement();statement.executeUpdate(sql);5 释放资源statement.close();con.close();*/
  6. MappedStatement是个什么东西呢,它对应着我们的一条SQL语句。
    是通过MapperMethod的command对象的name属性,从configuration里边拿到的。
  7. 拿到MappedStatement之后调用 executor的query方法,这个方法是CachingExecutor提供的。
    可以看到,这里通过MappedStatement获取了我们的SQL,然后生成一个缓存key,想起我记忆深处的mybatis一级二级缓存。
    之后返回调用query方法的结果。

    @Override
    public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {BoundSql boundSql = ms.getBoundSql(parameterObject);CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    }
  8. 还是进入CachingExecutor的query方法。看来Executor这样的类就是真正执行数据库操作的类了。
    看到先是从MappedStatement里边拿缓存,如果是空的,就调用delegate.query,
    delegate是SimpleExecutor类型,顾名思义CachingExecutor委派了SimpleExecutor来进行数据库操作。

    @Override
    public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)throws SQLException {Cache cache = ms.getCache();if (cache != null) {flushCacheIfRequired(ms);if (ms.isUseCache() && resultHandler == null) {ensureNoOutParams(ms, parameterObject, boundSql);@SuppressWarnings("unchecked")List<E> list = (List<E>) tcm.getObject(cache, key);if (list == null) {list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);tcm.putObject(cache, key, list); // issue #578 and #116}return list;}}return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    }
  9. 继续看SimpleExecutor里边的query,看到SimpleExecutor里边并没有query方法,
    而是SimpleExecutor继承了BaseExecutor,query是BaseExecutor类提供的。
    第一句断点进去之后,看到的是存起来的"executing a query",这是出异常时的堆栈信息。
    emm..然后就是很多是否存在缓存是否使用缓存的代码。
    咱直接看queryFromDatabase(),这个命名明显告诉玩家,BOSS就在前面了。

    @SuppressWarnings("unchecked")
    @Override
    public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());if (closed) {throw new ExecutorException("Executor was closed.");}if (queryStack == 0 && ms.isFlushCacheRequired()) {clearLocalCache();}List<E> list;try {queryStack++;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);}} finally {queryStack--;}if (queryStack == 0) {for (DeferredLoad deferredLoad : deferredLoads) {deferredLoad.load();}// issue #601deferredLoads.clear();if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {// issue #482clearLocalCache();}}return list;
    }
  10. 同样是BaseExecutor 提供的 queryFromDatabase()方法。
    首先put进去了一个缓存,key是我们之前的缓存键,值是一个默认的值,感觉是占位的意思。
    然后执行doQuery方法,看到do开头的方法,就知道不简单。doGet doPost
    doQuery是个抽象方法,我们得去SimpleExecutor看实现。

    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;
    }
  11. SimpleExecutor.doQuery
    来啦! Statement!而且还有我们熟悉的prepareStatement字眼。哈哈 都是JDBC呀
    最后看到是由一个Handler来执行的,看一看这个Handler。

    @Override
    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 = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);stmt = prepareStatement(handler, ms.getStatementLog());return handler.<E>query(stmt, resultHandler);} finally {closeStatement(stmt);}
    }
  12. 先是进入到了RoutingStatementHandler,然后RoutingStatementHandler委托给了PreparedStatementHandler,下边是PreparedStatementHandler的query。
    看到想看的东西了,ps.execute()
    之后将结果交给resultSetHandler处理。

    @Override
    public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {PreparedStatement ps = (PreparedStatement) statement;ps.execute();return resultSetHandler.<E> handleResultSets(ps);
    }
  13. 回顾一下,我们的问题是MapperMethod对象的execute方法做了什么,结论就是,
    我们通过MapperMethod的command属性和method属性,知道了要执行的SQL的类型,
    这里我们走的是SELECT路线。知道类型之后,交由SqlSession执行selectOne方法。
    然后又调用了DefaultSqlSession的selectList方法,DefaultSqlSession表示不想干活,
    就交给了勤劳的BaseExecutor,BaseExecutor的里边有query方法,query方法做一些通用操作,
    看一眼有没有缓存呀这些。在没有或不用缓存的情况下,再去调doQuery方法,doQuery方法有不同的实现。
    在doQuery以及其要调用方法里边使用的就是我们熟悉的JDBC。执行完操作之后,将结果交给resultSetHandler。

总结

  • 我们使用的是什么?

答:使用的是我们的接口的代理类的实例。
在构造代理类的实例时,
我们传入了实现了InvocationHandler接口的MapperProxy实例,
当代理对象调用方法时,会进入MapperProxy的invoke方法。
在invoke方法中通过Method对象找MapperMethod,
然后执行MapperMethod对象的execute方法。
在这里,代理的作用是,让我们知道哪个接口的哪个方法被使用了。

MapperProxy 对应了我们的一个接口,
MapperMethod 对应接口里的一个方法,
MappedStatement 对应一条SQL

  • 从上边流程中,总结各个类的职责

MapperProxy: 定义代理对象调用方法时执行的动作。
即在invoke()里拿到调用的方法对应的MapperMethod,然后调用MapperMethod的execute。

MapperMethod: 对应我们接口里的方法,持有SqlCommand(command)和MethodSignature(method),
可以知道方法的全名以及对应的SQL的类型。

MappedStatement: 保存的SQL的信息。

SqlSession: 玩家获取Mapper的地方。假装执行SQL,实际交给了Executor。

Executor: 真正执行数据库操作。

大致知道流程是什么样的,接着就可以模仿着写一写了...
emm...感觉没这么简单。

mybatis模仿1之我先看看相关推荐

  1. 我的mybatis plus——全公司同事开始模仿了!

    点击上方"Java基基",选择"设为星标" 做积极的人,而不是积极废人! 每天 14:00 更新文章,每天掉亿点点头发... 源码精品专栏 原创 | Java ...

  2. 玩转Mybatis中的类型转换器TypeHandler

    点击蓝色"程序猿DD"关注我 回复"资源"获取独家整理的学习资料! 抛开使用场景谈技术都是耍流氓 1. 场景 日常java开发中经常有这种需求,用0或者1这些代 ...

  3. redis springmvc mysql_SpringMVC + MyBatis + Mysql + Redis(作为二级缓存) 配置

    项目环境: 在SpringMVC + MyBatis + Mysql.Redis部署在Linux虚拟机. 1.整体思路 参考Ehcache实现MyBatis二级缓存代码(Maven引用对应jar查阅) ...

  4. mybatis 取list第一个_Mybatis(五)- 多对一

    步骤1:先运行,看到效果,再学习步骤2:模仿和排错步骤3:多对一关系步骤4:学习之前先看效果步骤5:修改Product.java步骤6:Product.xml步骤7:mybatis-config.xm ...

  5. oracle 返回hashmap,解决:oracle+myBatis ResultMap 类型为 map 时返回结果中存在 timestamp 时使用 jackson 转 json 报错...

    前言:最近在做一个通用查询单表的组件,所以 sql 的写法就是 select *,然后 resultType="map" ,然后使用 jackson @ResponseBody 返 ...

  6. casewhen多条件查询_Mybatis(三)- MyBatis 多条件查询和模糊查询实例

    步骤1:先运行,看到效果,再学习步骤2:模仿和排错步骤3:在前一步的基础上进行步骤4:模糊查询步骤5:多条件查询 步骤 1 : 先运行,看到效果,再学习 老规矩,先下载下载区(点击进入)的可运行项目, ...

  7. 我的java学习之路之Mybatis

    前言:从学习java web开始到现在开始学习框架,也有了小半年的时间.这期间的状态也是半懂不懂,好多东西知道代码怎么写,但是却说不出来是什么,所以想通过这样的方式记录下来,让自己印象更加深刻点 ps ...

  8. 模仿天猫实战【SSM版】——后台开发

    上一篇文章链接:模仿天猫实战[SSM版]--项目起步 后台需求分析 在开始码代码之前,还是需要先清楚自己要做什么事情,后台具体需要实现哪些功能: 注意: 订单.用户.订单.推荐链接均不提供增删的功能. ...

  9. SpringMVC + MyBatis + MySQL + Redis(作为二级缓存) 配置

    前言 Mybatis 有二级缓存,为什么还要用Redis? mybais一级缓存作用域是session,session commit之后缓存就失效了. mybais二级缓存作用域是sessionfac ...

最新文章

  1. 2022-2028年中国锂电材料产业投资分析及前景预测报告
  2. AI找Bug,一键快速预测
  3. bzoj 1050: [HAOI2006]旅行comf(codevs.cn 1001 舒适的路线) 快排+并查集乱搞
  4. 周源:视频加密和DRM实施实践
  5. 数学_同取对数(HDU_1060)
  6. Python排序 插入排序
  7. java实现区块链p2p网络_详解区块链P2P网络
  8. 主要几个浏览器的内核是什么
  9. [Luogu3554] Poi2013 Triumphal arch
  10. Sverlet案例小萌神服务器端
  11. appium远程连接---逍遥模拟器
  12. 有关H5第六章的背景与阴影介绍
  13. 单绞机控制算法模型(Simulink仿真)
  14. 重启mysql的cmd命令
  15. 还在为网速烦恼?你可能没有使用华为云CDN加速服务
  16. C++20 barrier
  17. 酷狗 KRC 文件的解析
  18. spring boot 集成Zuul
  19. Ubuntu 18.04安装JDK 1.8
  20. End-to-End Knowledge-Routed Relational Dialogue System for Automatic Diagnosis翻译

热门文章

  1. English--基础知识点--2--独立主格
  2. 审核BSCI时工厂常出现的问题点
  3. UI设计规范技巧——文件整理
  4. gbase 8a 配置手册
  5. GBase 8a集群目录结构
  6. 百度搜索中“鱼龙混杂”的加盟信息,如何靠AI 解决?
  7. 张韶涵《口袋的天空》小提琴谱片段
  8. eter测试软件,AcCellerator高通量单细胞力学荧光测试分析系统
  9. spotlight on linux 安装及配置
  10. 备战NOIP每周写题记录(一)···不间断更新