大体流程: 具体流程以查询为例: 将定义的接口方法转换成 二级缓存执行器,这里没有用到 抽像类,基础执行器,包括一级缓存逻辑在此实现 执行器用于连接 SqlSession与JDBC,所有与JDBC相关的操作都要通过它。 与mybatis的执行器是不同的概念。 Executor的基本抽象实现,里边提取了连接维护、一级缓存的公有功能,供子类复用。并放出了doQuery、doUpdate的抽象方法下放到子类做差异实现。 作为BaseExecutor的一个装饰器,用来负责二级缓存功能。而JDBC相关操作都是丢给BaseExecutor来操作。 默认执行器 可重用执行器,底层是维护了一个 批处理执行器,其实底层依赖的就是JDBC的 从上面这个方法可以发现, 源码中并没有体现出同步和锁的概念,所以 在同一会话内如果有两次相同的查询(Sql和参数均相同),那么第二次就会命中缓存。一级缓存通过会话进行存储,当会话关闭,缓存也就没有了。此外如果会话进行了修改(增删改) 操作,缓存也会被清空。 myBatis 默认是开启一级缓存的,且不可以关闭。 Spring每次执行Sql请求都会通过MyBatis获取一个新的SqlSession自然就不会命中一级缓存了。解决办法是给服务方法添加事务,通常只有增删改操作会添加事务,而如果是纯查询的我们会勿略事务,事物对于查询也是有必要的。 二级缓存是应用级的缓存,即作用于整个应用的生命的周期。相对一级缓存会有更高的命中率。所以在顺序上是先访问二级然后在是一级和数据库。 虽然xml配置和注解的功能基本相同,但是使用@CacheNamespace时候要注意:配置文件和接口注释是不能够配合使用的。只能通过全注解的方式或者全部通过xml配置文件的方式使用。 全局配置文件中开启缓存 SQL 映射文件(mapper.xml)中添加: 与一级缓存的实时写入不同,二级缓存是在事务提交或会话关闭之后才会触发缓存写入。 不同的 同时使用注解与xml映射文件时,虽然它们namespace相同但一样不能共享缓存,这就必须一方设定缓存,另一方引用才可以。 二级缓存是在事务提交后才会写入,目的是为了防止其它会话脏读缓存。所以在话与二级缓存中间会有一个事物缓存管理器,会话其间查询的数据会放到管理器的暂存区。当事务提交后会才会写入指定二级缓存区域。管理器的生命周期与会话保持一至。 暂时存放待缓存的数据区域,和缓存区是一一对应的。如果会话会涉及多个二级缓存的访问,那么对应暂存区也会有多个。暂存区生命周期与会话保持一至。 缓存区是通过Mapper声明而获得,默认每个Mapper都有独立的缓存区。其作用是真正存放数据和实现缓存的业务逻辑。如序列化、防止缓存穿透、缓存有效期等。 在设计上采用的是装饰器模式。即不同的功能由不同缓存装饰器实现。下表为装饰器类和对应的功能。 每个装饰器都会通过属性引用下一个装饰器,从而组成一个链条。缓存逻辑基于链条进行传递。 一般plugin实现Interceptor接口,并在xml中进行配置 插件初始化完成之后,添加插件的流程如下: 使用的时候都是用动态代理将多个插件用责任链的方式添加的,最后返回的是一个代理对象,最终动态代理生成和调用的过程都在 Plugin 类中: 签名Map就是获得注解 https://www.cnblogs.com/UYGHYTYH/p/12995060.html文章目录
主流程
方法代理MapperProxy->会话SQLSession->执行器Executor->声明处理器StatementHandler/JDBC
MapperProxy#invoke
MapperProxy
用于实现动态代理,是InvocationHandler
接口的实现类。与MyBatis交互的门面,存在的目的是为了方便调用,本身不会影响执行逻辑。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); }
MapperMethod#execute
MappedStatement
对象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); if (method.returnsOptional() && (result == null || !method.getReturnType().equals(result.getClass()))) { result = Optional.ofNullable(result); } } 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; }
DefaultSqlSession#selectOne
@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.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; } }
DefaultSqlSession#selectList
@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 { 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(); } }
CachingExecutor#query
@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, boundSql); @SuppressWarnings("unchecked") List<E> list = (List) tcm.getObject(cache, key); if (list == null) { list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); tcm.putObject(cache, key, list); // issue #578 and #116 } return list; } } //没有缓存,这里 return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); }
BaseExecutor#query
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) 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 #601 deferredLoads.clear(); if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { // issue #482 clearLocalCache(); } } return list; }
BaseExecutor#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; }
SimpleExecutor#doQuery
@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.query(stmt, resultHandler); } finally { closeStatement(stmt); } }
SimpleExecutor#prepareStatement
private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException { Statement stmt; //获取连接 Connection connection = getConnection(statementLog); //预处理SQL stmt = handler.prepare(connection, transaction.getTimeout()); //设置参数 handler.parameterize(stmt); return stmt; }
执行器Executor
可以在mybatis-config.xml
里边指定使用执行器,默认为SimpleExecutor
。 <settings> <setting name="defaultExecutorType" value="REUSE"/> </settings>
JDBC原生执行器Statement
SimpleExecutor
、ReuseExecutor
、BatchExecutor
三个具体的实现均是实际操作JDBC的对象,可以通过Mapper
接口注解@Options(statementType=StatementType.STATEMENT|PREPARED|CALLABLE)
。默认使用PreparedStatement
来处理JDBC操作BaseExecutor
CachingExecutor
SimpleExecutor
ReuseExecutor
Map<String sql,Statement stmt>
来捕捉到相同的SQL
,则直接取对应缓存的Statement
进行执行,所以对于相同SQL
(包括query
、update
),不同参数,则只进行一次预编译。就可以复用设置参数来执行。//statement缓存 private final Map<String, Statement> statementMap = new HashMap<String, Statement>(); private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException { Statement stmt; BoundSql boundSql = handler.getBoundSql(); String sql = boundSql.getSql(); //判断是否已经有SQL对应statement if (hasStatementFor(sql)) { //如果有,从map中取 stmt = getStatement(sql); applyTransactionTimeout(stmt); } else { Connection connection = getConnection(statementLog); stmt = handler.prepare(connection, transaction.getTimeout()); //如果没有,执行完成后,将SQL和statement加入map putStatement(sql, stmt); } handler.parameterize(stmt); return stmt; } private boolean hasStatementFor(String sql) { try { return statementMap.keySet().contains(sql) && !statementMap.get(sql).getConnection().isClosed(); } catch (SQLException e) { return false; } } private Statement getStatement(String s) { return statementMap.get(s); } private void putStatement(String sql, Statement stmt) { statementMap.put(sql, stmt); }
ReuseExecutor
维护了一个Statement
的缓存,这是Mybatis里边除了一级缓存、二级缓存以外的缓存。一般来说将执行器指定为ReuseExecutor
,也是一种提升性能的方案。BatchExecutor
Statement.addBatch
接口规范。所以,BatchExecutor
的使用必须是以addBatch
开始,并以doFlushStatement
结束。不同的是,BatchExecutor
并不是单调的直接使用addBatch
,而是对其扩展了缓存,复用的能力。public class BatchExecutor extends BaseExecutor { public static final int BATCH_UPDATE_RETURN_VALUE = Integer.MIN_VALUE + 1002; //存放addBatch加入的Statement private final List<Statement> statementList = new ArrayList<Statement>(); //每个Statement执行后返回的结果集 private final List<BatchResult> batchResultList = new ArrayList<BatchResult>(); //当前SQL private String currentSql; //当前statement private MappedStatement currentStatement; public BatchExecutor(Configuration configuration, Transaction transaction) { super(configuration, transaction); } @Override public int doUpdate(MappedStatement ms, Object parameterObject) throws SQLException { final Configuration configuration = ms.getConfiguration(); final StatementHandler handler = configuration.newStatementHandler(this, ms, parameterObject, RowBounds.DEFAULT, null, null); final BoundSql boundSql = handler.getBoundSql(); final String sql = boundSql.getSql(); final Statement stmt; //每次执行一个Statement时都会去判断上次执行的SQL(currentSql) //与上次执行的Statement(currentStatement)能否复用, //如果能复用就不在创建Statement,即省去了重复预编译过程。 if (sql.equals(currentSql) && ms.equals(currentStatement)) { int last = statementList.size() - 1; stmt = statementList.get(last); applyTransactionTimeout(stmt); handler.parameterize(stmt);//fix Issues 322 BatchResult batchResult = batchResultList.get(last); batchResult.addParameterObject(parameterObject); } else { Connection connection = getConnection(ms.getStatementLog()); stmt = handler.prepare(connection, transaction.getTimeout()); handler.parameterize(stmt); //fix Issues 322 currentSql = sql; currentStatement = ms; statementList.add(stmt); batchResultList.add(new BatchResult(ms, sql, parameterObject)); } // handler.parameterize(stmt); handler.batch(stmt); return BATCH_UPDATE_RETURN_VALUE; } }
StatementHandler/ResultHandler
@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 StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql); stmt = prepareStatement(handler, ms.getStatementLog()); //这里resultHandler return handler.<E>query(stmt, resultHandler); } finally { closeStatement(stmt); } }
executor
里面的逻辑实现是依赖这两个Handler
可以通过Mapper
接口注解@Options(statementType=StatementType.STATEMENT|PREPARED|CALLABLE)
来指定Statement
。默认使用PreparedStatement
来处理JDBC操作
执行时序
Configuration
获取StatementHandler
实例(由statementType
决定)。Statement
对象Statement#execute
线程安全问题
Executor
不能跨线程操作,相应的Sqlsession
也是不能跨线程操作的,而单线程是可以操作多个Sqlsession
的。一级缓存
命中条件
清空场景
SqlSession.clearCache()
方法。flushCache= true
,查询前会清空全部缓存。关闭一级缓存
useCache=false
只能关闭二级缓存,不能关闭一级缓存。如果一定要关闭一级缓存只能在查询中配置flushCache=true
.为什么Spring中Mybatis一级缓存失效
实现流程
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."); } //先清局部缓存,再查询.但仅查询栈为0,才清。为了处理递归调用 if (queryStack == 0 && ms.isFlushCacheRequired()) { clearLocalCache(); } List<E> list; try { //加一,这样递归调用到上面的时候就不会再清局部缓存了 queryStack++; //先根据cachekey从localCache去查 list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; if (list != null) { //若查到localCache缓存,处理localOutputParameterCache 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 #601 //清空延迟加载队列 deferredLoads.clear(); if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { // issue #482 //如果是STATEMENT,清本地缓存 clearLocalCache(); } } return list; }
localCache
是一个PerpetualCache
类 ,具体存储容器就是HashMap
public class PerpetualCache implements Cache { private String id; private Map<Object, Object> cache = new HashMap<Object, Object>(); ...
二级缓存
由于生命周期长,跨会话访问的因素所以二级在使用上要更谨慎,如果用的不好就会造成脏读。使用
配置方式
XML配置
<setting name="cacheEnabled" value="true"/>
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="cn.mybatis.UserMapper"> <cache type="cn.mybatis.MybatisRedisCache"> <property name="eviction" value="LRU" /> <property name="flushInterval" value="6000000" /> <property name="size" value="1024" /> <property name="readOnly" value="false" /> </cache> <select id="selectById"> select * from test where id = #{id} </select > </mapper>
注解配置
@CacheNamespace(implementation = MybatisRedisCache.class) public interface UserMapper( @Select("select * from t_user where user_id = #{userId}") @Options(useCache = true) List<User> getUser(User u); }
命中条件
statement id
ResultHandler
来自定义返回数据UseCache=false
来关闭缓存FlushCache=true
来清空缓存Parameter
中mode=out|inout
缓存写入
因为二级缓存是跨会话的,如果没有提交就写入,如果事务最后回滚,肯定导致别的会话脏读。缓存更新
update
操作会清空该namespace
下的缓存(可设定flushCache=false
来禁止)。<!-- flushCache true - 默认 执行该语句时,会刷新二级缓存 false - 执行该语句时,不会刷新二级缓存 --> <delete id="delete" flushCache="false"> delete from user where user_id = #{userId} </delete>
flushInterval
。useCache=false
。Statement
设定 flushCache=true
清空缓存缓存引用
namespace
有着独立的缓存容器,只有该namespace
下的statement
才能访问该缓存。但表与表之间是存在关联的。而对应的Mapper
又是独立的。这时我们就可以通过缓存引用,让多个Mapper
共享一个缓存。具体做法是设定@CacheNamespaceRef
与 指定namespace
值就可以。源码解析
事务缓存管理器(TransactionalCacheManager)
暂存区 TransactionalCache
缓存区
装饰器
功能
SynchronizedCache
同步锁,用于保证对指定缓存区的操作都是同步的
LoggingCache
统计器,记录缓存命中率
BlockingCache
阻塞器,基于key加锁,防止缓存穿透
ScheduledCache
时效检查,用于验证缓存有效器,并清除无效数据
LruCache
溢出算法,淘汰闲置最久的缓存
FifoCache
溢出算法,淘汰加入时间最久的缓存
WeakCache
溢出算法,基于java弱引用规则淘汰缓存
SoftCache
溢出算法,基于java软引用规则淘汰缓存
PerpetualCache
实际存储,内部采用HashMap进行存储
拦截器 Interceptor
添加plugin流程
<plugins> <plugin interceptor="***.interceptor1"/> <plugin interceptor="***.interceptor2"/> ... <!-- com.github.pagehelper为PageHelper类所在包名 --> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <!-- 设置数据库方言 --> <property name="helperDialect" value="mysql"/> <!-- 合理化分页 --> <property name="reasonable" value="true"/> </plugin> </plugins>
//Configuration类 public class Configuration { //拦截器链,责任链模式 protected final InterceptorChain interceptorChain = new InterceptorChain(); //mybatis 插件的拦截目标有四个 //Executor、StatementHandler、ParameterHandler、ResultSetHandler public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) { ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql); //这里 parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler); return parameterHandler; } public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler, ResultHandler resultHandler, BoundSql boundSql) { ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds); //这里 resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler); return resultSetHandler; } public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql); //这里 statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; } public Executor newExecutor(Transaction transaction) { return newExecutor(transaction, defaultExecutorType); } public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { executor = new CachingExecutor(executor); } //这里 executor = (Executor) interceptorChain.pluginAll(executor); return executor; } }
public class InterceptorChain { private final List<Interceptor> interceptors = new ArrayList<Interceptor>(); public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); } return target; } }
public class Plugin implements InvocationHandler { private Object target; private Interceptor interceptor; private Map<Class<?>, Set<Method>> signatureMap; public static Object wrap(Object target, Interceptor interceptor) { // 获取签名Map Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor); // 拦截目标 (ParameterHandler|ResultSetHandler|StatementHandler|Executor) Class<?> type = target.getClass(); // 获取目标接口 Class<?>[] interfaces = getAllInterfaces(type, signatureMap); if (interfaces.length > 0) { return Proxy.newProxyInstance( type.getClassLoader(), interfaces, new Plugin(target, interceptor, signatureMap)); } return target; } //调用 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)) { return interceptor.intercept(new Invocation(target, method, args)); } return method.invoke(target, args); } catch (Exception e) { throw ExceptionUtil.unwrapThrowable(e); } }
@Signature
中的type
和method
@Intercepts({ @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class ExamplePlugin implements Interceptor { public Object intercept(Invocation invocation) throws Throwable { ... } }
参考
https://mp.weixin.qq.com/s/Oxjv4G0grivGQW3oNSlvKA
https://www.cnblogs.com/sanzao/p/11423849.html
本网页所有视频内容由 imoviebox边看边下-网页视频下载, iurlBox网页地址收藏管理器 下载并得到。
ImovieBox网页视频下载器 下载地址: ImovieBox网页视频下载器-最新版本下载
本文章由: imapbox邮箱云存储,邮箱网盘,ImageBox 图片批量下载器,网页图片批量下载专家,网页图片批量下载器,获取到文章图片,imoviebox网页视频批量下载器,下载视频内容,为您提供.
阅读和此文章类似的: 全球云计算