Mybatis-Plus的分页语句流程保姆级分析(四)

group : com.baomidou

version:3.5.2.2-SNAPSHOT

为什么要分析分页流程

因为我在使用的时候发现分页不生效,得分析一下找到原因。

问题描述:

我的分页不生效。

com.baomidou.mybatisplus.extension.plugins.pagination的Page对象。

代码如下:

public void getAll(){
    // 为什么我的分页实效了?
    Page<SkyworthUser> objects =Page.of(1,10);
    IPage<SkyworthUser> all = userInfoMapper.getAll(objects);
    System.out.println(all.getRecords().size());
}
public interface UserInfoMapper extends BaseMapper<SkyworthUser> {

    IPage<SkyworthUser> getAll(IPage<SkyworthUser> page);

    void updateId(@Param("id") String id,@Param("flag") int flag);
}
<select id="getAll" resultType="com.qhyu.cloud.model.SkyworthUser">
    Select * from skyworth_user
</select>

分析:

首先根据我们前几章的梳理,按照我的理解来画出整体流程图,然后分析那个地方可能出现问题。

在这里插入图片描述

首先这张图的细节在本章节后续会详细讲解,当前我的分页查询不生效的原因,我基本断定是没有将MybatisPlusInterceptor引入到项目中,然后需要将PaginationInnerInterceptor添加到MybatisPlusInterceptor的interceptors的属性中。

@ComponentScan("com.qhyu.cloud")
@MapperScan(value = "com.qhyu.cloud.mapper.**")
// spring会把META-INFO中的东西扫起来,注入到容器,我们用的spring,所以手动import进来
@Import(MybatisPlusAutoConfiguration.class)
public class StartConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    }
}

官网中其实也有说明,只是我误认为这玩意引入就自己配置好了。得多看官网!

分页源码分析

首先我这边整理了一个流程图,整个分页的流程其实是比较绕的,我也不确定能否描述清楚,接下来就进入主题。

在这里插入图片描述

以下是DefualtResultSetHandler的断点信息,拿到的栈信息可以很快的了解调用流程。

handleResultSets:222, DefaultResultSetHandler (org.apache.ibatis.executor.resultset)
query:65, PreparedStatementHandler (org.apache.ibatis.executor.statement)
query:79, RoutingStatementHandler (org.apache.ibatis.executor.statement)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invoke:64, Plugin (org.apache.ibatis.plugin)
query:-1, $Proxy42 (com.sun.proxy)
doQuery:63, SimpleExecutor (org.apache.ibatis.executor)
queryFromDatabase:325, BaseExecutor (org.apache.ibatis.executor)
query:156, BaseExecutor (org.apache.ibatis.executor)
query:109, CachingExecutor (org.apache.ibatis.executor)
willDoQuery:136, PaginationInnerInterceptor (com.baomidou.mybatisplus.extension.plugins.inner)
intercept:75, MybatisPlusInterceptor (com.baomidou.mybatisplus.extension.plugins)
invoke:62, Plugin (org.apache.ibatis.plugin)
query:-1, $Proxy41 (com.sun.proxy)
selectList:151, DefaultSqlSession (org.apache.ibatis.session.defaults)
selectList:145, DefaultSqlSession (org.apache.ibatis.session.defaults)
selectList:140, DefaultSqlSession (org.apache.ibatis.session.defaults)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invoke:427, SqlSessionTemplate$SqlSessionInterceptor (org.mybatis.spring)
selectList:-1, $Proxy36 (com.sun.proxy)
selectList:224, SqlSessionTemplate (org.mybatis.spring)
executeForIPage:122, MybatisMapperMethod (com.baomidou.mybatisplus.core.override)
execute:85, MybatisMapperMethod (com.baomidou.mybatisplus.core.override)
invoke:156, MybatisMapperProxy$PlainMethodInvoker (com.baomidou.mybatisplus.core.override)
invoke:95, MybatisMapperProxy (com.baomidou.mybatisplus.core.override)
getAll:-1, $Proxy37 (com.sun.proxy)
getAll:43, UserServiceImpl (com.qhyu.cloud.service.impl)
pageTest:33, MybatisQhyuApplication
main:28, MybatisQhyuApplication

MybatisMapperProxy

首先我们的mapper接口的请求会进入到MybatisMapperProxy的invoke方法。第三章我们说过,mapper会被创建为一个MybatisMapperProxy的代理对象。

method.getDeclaringClass()方法是用于获取定义某个方法的类的class对象,比如我们的hashcode方法和equals方法都是属于Object的,所以此处就是要让这些方法直接执行,因为我们的sql执行是需要走mybatis的逻辑。

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            if (Object.class.equals(method.getDeclaringClass())) {
                // getDeclaringClass 方法是用于获取定义某个方法的类的 Class 对象。
                // 如果方法是在当前类中定义的,则返回当前类的 Class 对象;如果方法是在父类或接口中定义的,则返回相应的父类或接口的 Class 对象。
                // 巧妙🤏
                return method.invoke(this, args);
            } else {
                // cachedInvoker会组装PlainMethodInvoker或者DefaultMethodInvoker
                return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
            }
        } catch (Throwable t) {
            throw ExceptionUtil.unwrapThrowable(t);
        }
    }

接下来就到了cachedInvoker方法,该方法返回的是一个MapperMethodInvoker。

默认方法是公共非抽象实例方法,才会进入到if的逻辑里面,因为当前是支持接口里面定义默认方法的,所以这里特殊处理一下。

后续的方法就比较好理解了,返回了一个PlainMethodInvoker对象,构造函数是MybatisMapperMethod、Method和Configuration。

achedInvoker(method).invoke(proxy, method, args, sqlSession)执行之后就进入了PlainMethodInvoker的invoke方法,从而执行MybatisMapperMethod的execute方法。此处比较好理解。

private MapperMethodInvoker cachedInvoker(Method method) throws Throwable {
    try {
        return CollectionUtils.computeIfAbsent(methodCache, method, m -> {
            // 这个是因为接口可以有默认方法,所以做特殊处理。
            // 所以正常的mapper接口都是会走PlainMethodInvoker。
            if (m.isDefault()) {
                try {
                    if (privateLookupInMethod == null) {
                        return new DefaultMethodInvoker(getMethodHandleJava8(method));
                    } else {
                        return new DefaultMethodInvoker(getMethodHandleJava9(method));
                    }
                } catch (IllegalAccessException | InstantiationException | InvocationTargetException
                    | NoSuchMethodException e) {
                    throw new RuntimeException(e);
                }
            } else {
                return new PlainMethodInvoker(new MybatisMapperMethod(mapperInterface, method, sqlSession.getConfiguration()));
            }
        });
    } catch (RuntimeException re) {
        Throwable cause = re.getCause();
        throw cause == null ? re : cause;
    }
}

MybatisMapperMethod

execute方法就是根据sqlCommandType来判断走什么分支,当前我们的方法是分页的Select方法,所以会进入到case SELECT逻辑。

我们返回的是IPage类型的对象,最终会进入到executeForIPage(sqlSession, args)逻辑中。

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 {
                // TODO 这里下面改了
                if (IPage.class.isAssignableFrom(method.getReturnType())) {
                    result = executeForIPage(sqlSession, args);
                    // TODO 这里上面改了
                } 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;
}

这个方法会拿到传递的IPage参数对象,给param属性,然后会把这个参数传递下去,需要注意的是result对象就是param对象。此处涉及到最后的Records的填充和total的信息。

我们看到的sqlSession.selectList方法其实就是最终返回的信息了,因为这个方法返回的内容就是我们需要的,有分页信息也有查询的信息。所以核心就是sqlSession.selectList方法。

private <E> Object executeForIPage(SqlSession sqlSession, Object[] args) {
    IPage<E> result = null;
    for (Object arg : args) {
        if (arg instanceof IPage) {
            result = (IPage<E>) arg;
            break;
        }
    }
    Assert.notNull(result, "can't found IPage for args!");
    Object param = method.convertArgsToSqlCommandParam(args);
    List<E> list = sqlSession.selectList(command.getName(), param);
    result.setRecords(list);
    return result;
}

SqlSessionTemplate

执行这个方法时候使用的是sqlSessionProxy属性,这个sqlSessionProxy就是一个代理对象,这些内容第三章我们说过。

public <E> List<E> selectList(String statement, Object parameter) {
  return this.sqlSessionProxy.selectList(statement, parameter);
}

此处的逻辑就是sqlSessionProxy调用selectList方法的时候进入SqlSessionInterceptor的invoke方法,然后生成sqlsession,用于DefaultSqlsession的selectList方法的调用。

private class SqlSessionInterceptor implements InvocationHandler {
  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
        SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
    try {
      Object result = method.invoke(sqlSession, args);
      if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
        // force commit even on non-dirty sessions because some databases require
        // a commit/rollback before calling close()
        sqlSession.commit(true);
      }
      return result;
    } catch (Throwable t) {
      Throwable unwrapped = unwrapThrowable(t);
      if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
        // release the connection to avoid a deadlock if the translator is no loaded. See issue #22
        closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
        sqlSession = null;
        Throwable translated = SqlSessionTemplate.this.exceptionTranslator
            .translateExceptionIfPossible((PersistenceException) unwrapped);
        if (translated != null) {
          unwrapped = translated;
        }
      }
      throw unwrapped;
    } finally {
      if (sqlSession != null) {
        closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
      }
    }
  }
}

注释:从Spring事务管理器获取一个SqlSession,如果需要的话,创建一个新的SqlSession。它尝试从当前事务中获取SqlSession,如果当前没有事务,则创建一个新的SqlSession。然后,如果Spring事务处于活动状态并且配置了SpringManagedTransactionFactory作为事务管理器,则将SqlSession与事务进行同步。

简单来说,这段注释描述了一个过程,该过程在使用Spring进行数据库事务管理时,从事务管理器中获取或创建一个SqlSession,并确保它与当前的Spring事务进行同步。

我们在MybatisAutoConfiguration 这章节就知道SqlSessionTemplate中的sqlSessionFactory为DefaultSqlSessionFactory对象,所以sessionFactory.openSession(executorType)的时候是进入DefaultSqlSessionFactory的openSession方法

public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType,
    PersistenceExceptionTranslator exceptionTranslator) {

  notNull(sessionFactory, NO_SQL_SESSION_FACTORY_SPECIFIED);
  notNull(executorType, NO_EXECUTOR_TYPE_SPECIFIED);

  SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);

  SqlSession session = sessionHolder(executorType, holder);
  if (session != null) {
    return session;
  }

  LOGGER.debug(() -> "Creating a new SqlSession");
  session = sessionFactory.openSession(executorType);

  registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);

  return session;
}

DefaultSqlSessionFactory

这个方法就是为了OpenSession、创建事物等,返回的是DefaultSqlSession对象。其中executor涉及到了责任链模式。Mybatis源码分析专栏里面也有关于责任链模式的描述。

既然返回的是DefaultSqlSession对象,那么SqlSessionInterceptor的invoke方法中method.invoke(sqlSession, args)就是调用的DefaultSqlSession的SelectList方法,并把参数带过去。

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
  Transaction tx = null;
  try {
    final Environment environment = configuration.getEnvironment();
    final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
    tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
    final Executor executor = configuration.newExecutor(tx, execType);
    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();
  }
}

DefaultSqlSession

这里传递的参数如下:

statement:com.qhyu.cloud.mapper.UserInfoMapper.getAll

prameter:Ipage对象,就是分页查询到的参数对象。

重点就是executor的调用了,因为这个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) {
  return selectList(statement, parameter, rowBounds, Executor.NO_RESULT_HANDLER);
}

private <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler) {
  try {
    MappedStatement ms = configuration.getMappedStatement(statement);
    return executor.query(ms, wrapCollection(parameter), rowBounds, handler);
  } catch (Exception e) {
    throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
  } finally {
    ErrorContext.instance().reset();
  }
}

statement:com.qhyu.cloud.mapper.UserInfoMapper.getAll是为了获取MappedStatement对象信息。

在这里插入图片描述

又因为我们配置了MybatisPlusInterceptor,而且executor在构建责任链的时候又创建了Plugin的代理对象,所以executor会进入到Plugin的invoke方法中。

Plugin

signatureMap属性值是在构建责任链的时候获取的,所以如果想知道signatureMap里面是什么需要查看wrap方法。

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);
  }
}

创建代理对象的时候,用到了局部变量signatureMap,这个值就是从getSignatureMap方法中获取而来。

public static Object wrap(Object target, Interceptor interceptor) {
  Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
  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;
}

我们可以从MybatisPlusInterceptor这个类的头上看到Intercepts注解和里面的Signature信息,根据方法我们可以知道应该就是把Signature信息组装成map返回回去。本意就是获取注解内的Signature信息。

private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) {
  Intercepts interceptsAnnotation = interceptor.getClass().getAnnotation(Intercepts.class);
  // issue #251
  if (interceptsAnnotation == null) {
    throw new PluginException("No @Intercepts annotation was found in interceptor " + interceptor.getClass().getName());
  }
  Signature[] sigs = interceptsAnnotation.value();
  Map<Class<?>, Set<Method>> signatureMap = new HashMap<>();
  for (Signature sig : sigs) {
    Set<Method> methods = MapUtil.computeIfAbsent(signatureMap, sig.type(), k -> new HashSet<>());
    try {
      Method method = sig.type().getMethod(sig.method(), sig.args());
      methods.add(method);
    } catch (NoSuchMethodException e) {
      throw new PluginException("Could not find method on " + sig.type() + " named " + sig.method() + ". Cause: " + e, e);
    }
  }
  return signatureMap;
}

MybatisPlusInterceptor的注解信息如下:

@Intercepts(
    {
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}),
        @Signature(type = StatementHandler.class, method = "getBoundSql", args = {}),
        @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}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
    }
)
public class MybatisPlusInterceptor implements Interceptor {

接着来阅读getSignatureMap方法的源码。

前面的逻辑比较清晰,就是获取到Intercepts注解的信息,拿到Signature数组信息,创建一个Map,key为Class、Vaule为一个Method集合。

这里面有个lambda表达式,我们来分析一下。

// 分析这个表达式
Set<Method> methods = MapUtil.computeIfAbsent(signatureMap, sig.type(), k -> new HashSet<>());

public static <K, V> V computeIfAbsent(Map<K, V> map, K key, Function<K, V> mappingFunction) {
    V value = map.get(key);
    if (value != null) {
      return value;
    }
    return map.computeIfAbsent(key, mappingFunction);
  }


@Override
    public V computeIfAbsent(K key,
                             Function<? super K, ? extends V> mappingFunction) {
        if (mappingFunction == null)
            throw new NullPointerException();
        int hash = hash(key);
        Node<K,V>[] tab; Node<K,V> first; int n, i;
        int binCount = 0;
        TreeNode<K,V> t = null;
        Node<K,V> old = null;
        if (size > threshold || (tab = table) == null ||
            (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((first = tab[i = (n - 1) & hash]) != null) {
            if (first instanceof TreeNode)
                old = (t = (TreeNode<K,V>)first).getTreeNode(hash, key);
            else {
                Node<K,V> e = first; K k;
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k)))) {
                        old = e;
                        break;
                    }
                    ++binCount;
                } while ((e = e.next) != null);
            }
            V oldValue;
            if (old != null && (oldValue = old.value) != null) {
                afterNodeAccess(old);
                return oldValue;
            }
        }
        V v = mappingFunction.apply(key);
        if (v == null) {
            return null;
        } else if (old != null) {
            old.value = v;
            afterNodeAccess(old);
            return v;
        }
        else if (t != null)
            t.putTreeVal(this, tab, hash, key, v);
        else {
            tab[i] = newNode(hash, key, v, first);
            if (binCount >= TREEIFY_THRESHOLD - 1)
                treeifyBin(tab, hash);
        }
        ++modCount;
        ++size;
        afterNodeInsertion(true);
        return v;
    }

一开始我们创建了一个signatureMap,里面是没有东西的,所以就会执行map.computeIfAbsent(key, mappingFunction)方法,也就是代码中的最后面一个方法(HashMap类的)。

参数:key为Signature中的属性type,mappingFunction就是k -> new HashSet<>()。

这段表达式的意思就是:如果signatureMap中有这个type类型的key就直接返回,否则就创建一个hashSet。用于装载Signature中的method参数。

所以signatureMap就是用于获取自定义的一些Interceptor上的注解信息,用于判断是否需要走窃听方法。

MybatisPlusInterceptor

这就到了MybatisPlusInterceptor的逻辑了。interceptors全局变量的值就是我们在最前面配置的PaginationInnerInterceptor。

开始会判断target是否是Executor类型,在构建责任链的时候target为CachingExecutor,其delegate为SimpleExecutor。所以此次会进入到if的逻辑,其中核心的就是进入Select逻辑执行interceptors的遍历,从而执行PaginationInnerInterceptor的willDoQuery、beforeQuery方法。

@Setter
private List<InnerInterceptor> interceptors = new ArrayList<>();

@Override
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) {
                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) {
                // 所以我这里感觉实现这个逻辑也可以打印sql信息
                innerInterceptor.beforePrepare(sh, connections, transactionTimeout);
            }
        }
    }
    return invocation.proceed();
}

PaginationInnerInterceptor

willDoQuery方法中会组装select count语句,核心的点就是executor执行query,此方法的executor是MybatisPlusInterceptor类中的intercept方法中从invocation.getTarget()中获取的,就是CachingExecutor的query方法,并将countSql和参数等都传递过去。

public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    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);
        // 在这里进行了优化,同时count sql在这里被组装好 也就是select count(*)
        String countSqlStr = autoCountSql(page, boundSql.getSql());
        PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
        countSql = new BoundSql(countMs.getConfiguration(), countSqlStr, mpBoundSql.parameterMappings(), parameter);
        PluginUtils.setAdditionalParameter(countSql, mpBoundSql.additionalParameters());
    }
    // 生成cacheKey 应该就是后续如果使用缓存的时候直接从cacheKey中获取,查询的时候会设置CacheKey
    CacheKey cacheKey = executor.createCacheKey(countMs, parameter, rowBounds, countSql);
    // 这里会到Executor的逻辑里面去。executor是一个属性值,不是一个代理对象。这里会执行一遍sql。
    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);
}

CachingExecutor

不管Cache的事情,都是会执行delegate.query方法,我们知道delegate其实就是SimpleExecutor,要知道SimpleExecutor继承了BaseExecutor哈。所以执行的其实是BaseExecutor的query方法。

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<E>) 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

这个基础的执行器执行sql是在queryFromDatabase方法中。

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 #601
    deferredLoads.clear();
    if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
      // issue #482
      clearLocalCache();
    }
  }
  return list;
}

核心逻辑又到了doQuery方法,其中的cache什么的我们暂且不管,后续缓存章节的时候再详细分析。

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

在这个方法中声明了一个处理器,StatementHandler,我们在Intercepts的type属性中见过。

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);
  }
}
Configuration

创建了一个RoutingStatementHandler,并且还是为其创建了责任链。所以我们知道这个handler执行的时候还是会进入Plugin的invoke逻辑进行验证,通过才会执行intercept。

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;
}

创建完handler之后继续执行prepareStatement方法,我们知道prepare方法是可以进入到interceptor逻辑的,从Intercepts注解中可以得知。

所以此时的prepare方法会进入到Plugin,并且通过验证进入intercept逻辑。

private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
  Statement stmt;
  Connection connection = getConnection(statementLog);
  stmt = handler.prepare(connection, transaction.getTimeout());
  handler.parameterize(stmt);
  return stmt;
}

只是此时的MybatisPlusInterceptor中会进入的else的逻辑,如下所示:

// 省略部分代码
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) {
            // 所以我这里感觉实现这个逻辑也可以打印sql信息
            innerInterceptor.beforePrepare(sh, connections, transactionTimeout);
        }
    }
}
return invocation.proceed();

args是不为空的,进入else逻辑,执行beforePrepare,是个空方法没有实现,所以invocation.proceed就进入了RoutingStatementHandler的prepare方法。

RoutingStatementhandler

其属性delegate就是PreparedStatementHandler

public Statement prepare(Connection connection, Integer transactionTimeout) throws SQLException {
  return delegate.prepare(connection, transactionTimeout);
}

BaseStatementHandler

PreparedStatementHandler继承了BaseStatementHandler,实际执行就是BaseStatementHandler的prepare方法。

public Statement prepare(Connection connection, Integer transactionTimeout) throws SQLException {
  ErrorContext.instance().sql(boundSql.getSql());
  Statement statement = null;
  try {
    statement = instantiateStatement(connection);
    setStatementTimeout(statement, transactionTimeout);
    setFetchSize(statement);
    return statement;
  } catch (SQLException e) {
    closeStatement(statement);
    throw e;
  } catch (Exception e) {
    closeStatement(statement);
    throw new ExecutorException("Error preparing statement.  Cause: " + e, e);
  }
}

instantiateStatement实力化一个Statement。connection为ConnectionLogger

ConnectionLogger

此处返回的Object,构建了一个PreparedStatementLogger。返回之前调用了ConnectionLogger的invoke方法,打印一些debug信息。

例如:10:29:40.874 [main] DEBUG com.qhyu.cloud.mapper.UserInfoMapper.getAll_mpCount - ==> Preparing: SELECT COUNT(*) AS total FROM skyworth_user

public Object invoke(Object proxy, Method method, Object[] params)
    throws Throwable {
  try {
    if (Object.class.equals(method.getDeclaringClass())) {
      return method.invoke(this, params);
    }
    if ("prepareStatement".equals(method.getName()) || "prepareCall".equals(method.getName())) {
      if (isDebugEnabled()) {
        debug(" Preparing: " + removeExtraWhitespace((String) params[0]), true);
      }
      PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
      stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
      return stmt;
    } else if ("createStatement".equals(method.getName())) {
      Statement stmt = (Statement) method.invoke(connection, params);
      stmt = StatementLogger.newInstance(stmt, statementLog, queryStack);
      return stmt;
    } else {
      return method.invoke(connection, params);
    }
  } catch (Throwable t) {
    throw ExceptionUtil.unwrapThrowable(t);
  }
}

执行完成之后返回到SimpleExecutor的prepareStatement,因为之前我们在此处进去了,所以执行完成之后回到这里。

private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
    Statement stmt;
    Connection connection = getConnection(statementLog);
    stmt = handler.prepare(connection, transaction.getTimeout());
    handler.parameterize(stmt);
    return stmt;
  }

继续执行parameterize(stmt)方法,也同样会进入到Plugin的invoke方法进行验证,因为MybatisPlusInterceptor没有相关的method信息,所以此处不会进入Interceptor的intercept的逻辑,继而直接执行RoutingStatementHandler的parameterize方法。

再往外层返回就到了SimpleExecutor的doQuey方法了。此时返回获取的stmt是PreparedStatementLogger,一看就是为了调用前打印sql相关信息的。

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);
    // 到这里出来,继续执行后面的query逻辑
    stmt = prepareStatement(handler, ms.getStatementLog());
    return handler.query(stmt, resultHandler);
  } finally {
    closeStatement(stmt);
  }
}

继续执行RoutingStatementHandler的query逻辑,实际执行的是delegate.query,也就是PreparedStatementHandler的Query方法。

PreparedStatementHandler

传入的参数statement是PreparedStatementLogger,所以就是执行PreparedStatementLogger的invoke方法

public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
  PreparedStatement ps = (PreparedStatement) statement;
  ps.execute();
  return resultSetHandler.handleResultSets(ps);
}

PreparedStatementLogger

打印sql的参数信息和返回信息的。

public Object invoke(Object proxy, Method method, Object[] params) throws Throwable {
  try {
    if (Object.class.equals(method.getDeclaringClass())) {
      return method.invoke(this, params);
    }
    if (EXECUTE_METHODS.contains(method.getName())) {
      if (isDebugEnabled()) {
        debug("Parameters: " + getParameterValueString(), true);
      }
      clearColumnInfo();
      if ("executeQuery".equals(method.getName())) {
        ResultSet rs = (ResultSet) method.invoke(statement, params);
        return rs == null ? null : ResultSetLogger.newInstance(rs, statementLog, queryStack);
      } else {
        return method.invoke(statement, params);
      }
    } else if (SET_METHODS.contains(method.getName())) {
      if ("setNull".equals(method.getName())) {
        setColumn(params[0], null);
      } else {
        setColumn(params[0], params[1]);
      }
      return method.invoke(statement, params);
    } else if ("getResultSet".equals(method.getName())) {
      ResultSet rs = (ResultSet) method.invoke(statement, params);
      return rs == null ? null : ResultSetLogger.newInstance(rs, statementLog, queryStack);
    } else if ("getUpdateCount".equals(method.getName())) {
      int updateCount = (Integer) method.invoke(statement, params);
      if (updateCount != -1) {
        debug("   Updates: " + updateCount, false);
      }
      return updateCount;
    } else {
      return method.invoke(statement, params);
    }
  } catch (Throwable t) {
    throw ExceptionUtil.unwrapThrowable(t);
  }
}

DefaultResultSetHandler

默认的返回处理类,最终将数据进行返回。

public List<Object> handleResultSets(Statement stmt) throws SQLException {
  ErrorContext.instance().activity("handling results").object(mappedStatement.getId());

  final List<Object> multipleResults = new ArrayList<>();

  int resultSetCount = 0;
  ResultSetWrapper rsw = getFirstResultSet(stmt);

  List<ResultMap> resultMaps = mappedStatement.getResultMaps();
  int resultMapCount = resultMaps.size();
  validateResultMapsCount(rsw, resultMapCount);
  while (rsw != null && resultMapCount > resultSetCount) {
    ResultMap resultMap = resultMaps.get(resultSetCount);
    handleResultSet(rsw, resultMap, multipleResults, null);
    rsw = getNextResultSet(stmt);
    cleanUpAfterHandlingResultSet();
    resultSetCount++;
  }

  String[] resultSets = mappedStatement.getResultSets();
  if (resultSets != null) {
    while (rsw != null && resultSetCount < resultSets.length) {
      ResultMapping parentMapping = nextResultMaps.get(resultSets[resultSetCount]);
      if (parentMapping != null) {
        String nestedResultMapId = parentMapping.getNestedResultMapId();
        ResultMap resultMap = configuration.getResultMap(nestedResultMapId);
        handleResultSet(rsw, resultMap, null, parentMapping);
      }
      rsw = getNextResultSet(stmt);
      cleanUpAfterHandlingResultSet();
      resultSetCount++;
    }
  }

  return collapseSingleResultList(multipleResults);
}

将直接返回到MybatisPlusInterceptor的query.beforeQuery的方法。

MybatisPlusInterceptor

很显然,后续还是会执行一遍整体流程,因为分页的时候先查询的总数,然后才去查询数据进行组装返回。

@Override
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) {
                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);
        } 
      // 省略部分代码

总结

因为分页的执行流程比较长,所以没有将整体流程全部都走一遍,因为后续的逻辑其实差不多,本章节的调用链路很深很深,有时候进去了容易出不来,所以在刚开始开的时候可以不像博主这样,了解一下内部是干嘛的就行,跳过看主体的方式是最快理解源码逻辑的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/261287.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【数据结构】线段树算法总结(区间修改)

知识概览 线段树一般有5个操作&#xff1a; pushup&#xff1a;用子节点更新当前节点信息pushdown&#xff1a;把懒标记往下传build&#xff1a;初始化一棵树modify&#xff1a;修改一个区间query&#xff1a;查询一个区间 不带懒标记&#xff08;支持单点修改&#xff09;的线…

猫罐头那种好吃又健康?五大值得买的猫罐头推荐

很多新手养猫的姐妹们都会为选罐头感到焦虑&#xff01;但是每种罐头都有优缺点&#xff0c;每只猫咪的胃口也都不同&#xff0c;只有适合自家猫的才是最好的。所以姐妹们在选罐头之前可以先做好功课&#xff0c;了解一下怎么选好的罐头。 作为一个已经离职的宠物医生&#xff…

6.6TB 全球地名路网透明标签瓦片地图

但凡要干一件稍微有意义的事&#xff0c;总会需要一定的时间积累&#xff0c;甚至还需要下不少的笨工夫&#xff0c;也正因如此&#xff0c;才会让这些最终做成的事更具有价值和意义。 比如我们曾在一个项目的助推下&#xff0c;就干了一件比较有意义的事情&#xff0c;尽管投入…

从实践角度优化数据库设计:深入解析三范式的应用

总述 第一范式(1NF):要求关系模式中的每个属性都是不可分的数据项,即属性具有原子性。第二范式(2NF):在满足1NF的基础上,要求关系模式中的所有非主属性都完全函数依赖于整个候选键(或主键)。第三范式(3NF):在满足2NF的基础上,要求关系模式中的每个非主属性都不传…

虚拟机的下载、安装

下载 vmware workstation&#xff08;收费的虚拟机&#xff09; 下载vbox 网址&#xff1a;Oracle VM VirtualBox&#xff08;免费的虚拟机&#xff09; 以下选择一个下载即可&#xff0c;建议下载vbox&#xff0c;因为是免费的。安装的时候默认下一步即可&#xff08;路径最好…

java并发编程四 Monitor 概念,api介绍与线程状态转换

Monitor 概念 Java 对象头 以 32 位虚拟机为例子&#xff1a; 普通对象 数组对象 其中 Mark Word 结构为 64 位虚拟机 Mark Word 小故事 故事角色 老王 - JVM小南 - 线程小女 - 线程房间 - 对象房间门上 - 防盗锁 - Monitor房间门上 - 小南书包 - 轻量级锁房间门上 -…

【实战】如何在Docker Image中轻松运行MySQL

定义 使用Docker运行MySQL有许多优势。它允许数据库程序和数据分离&#xff0c;增强了数据的安全性和可靠性。Docker Image的轻便性简化了MySQL的部署和迁移&#xff0c;而Docker的资源隔离功能确保了应用程序之间无冲突。结合中间件和容器化系统&#xff0c;Docker为MySQL提供…

java Filter内存马分析

目录 0x01 什么是Filter马 0x02 环境搭建 0x03 Filter内存马探索 1.tomcat Filter 的流程分析 2.攻击思路分析 0x04 Filter内存马exp编写 本文由掌控安全学院 - xilitter 投稿 知识基础&#xff1a; 刚开始内存马的这块学习与反序列化并无太大关系&#xff0c;反而与ja…

如何制作一本电子产品图册,打开线上推广呢

​随着互联网的普及和社交媒体的兴起&#xff0c;越来越多的企业开始注重线上传播。对于产品而言&#xff0c;制作一本精美的产品图册不仅可以展示产品的外观和特点&#xff0c;还可以通过线上传播吸引更多的潜在客户。 不会制作的朋友们&#xff0c;其实也不用担心&#xff0c…

使用 uiautomatorviewer 获取元素的定位信息

1. 使用 adb 连接设备&#xff08;真机或模拟器&#xff09; 连接夜神模拟器&#xff1a;adb connect 127.0.0.1:62001 连接MuMu模拟器&#xff1a;adb connect 127.0.0.1:7555 2. 打开 uiautomatorviewer 在 android-sdk --> tools 目录&#xff0c;找到 uiautomatorvie…

LeetCode Hot100 215.数组中的第k个最大元素

题目&#xff1a; 给定整数数组 nums 和整数 k&#xff0c;请返回数组中第 k 个最大的元素。 请注意&#xff0c;你需要找的是数组排序后的第 k 个最大的元素&#xff0c;而不是第 k 个不同的元素。 你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。 方法一&#xff…

获取请求体中json数据并解析到实体对象

目录 相关依赖 前端代码 后端代码 测试结果 相关依赖 <dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.83</version> </dependency> <dependency><groupId>comm…

上传app到app store的完整流程

上传ios的app到app store首先需要一个打包好的ipa文件。 要上传这个ipa必须要使用同一个苹果开发者账号的证书打包&#xff0c;才能上架到同一个app store上&#xff0c;假如是使用别人的证书打包的&#xff0c;只能上传到别人的app store账号。 假如你还没有创建证书&#x…

route 路由使用记录

一、路由的基本介绍 路由是计算机网络中的一个重要概念&#xff0c;它用于确定数据包从源地址到目的地址的路径。在网络中&#xff0c;路由器是负责转发数据包的设备。 下面是关于路由的基本知识和使用方法的介绍&#xff1a; 路由表&#xff1a;路由器通过路由表来确定数据包…

Excel 理解IF({1,0}...结构啥意思

背景知识&#xff1a; IF(条件,是则结果,否则结果) 逻辑真除了用True以外&#xff0c;还可以用不为0的数值&#xff0c;常用的是1&#xff1b;逻辑假除了用Fasle以外&#xff0c;还可以用数值0 理解公式 IF({1,0},B2:B8&C2:C8,D2:D8)就是构造一个二维数组&#xff0c;把…

Unity中Shader平移矩阵

文章目录 前言方式一&#xff1a;对顶点本地空间下的坐标进行相加平移1、在属性面板定义一个四维变量记录在 xyz 上平移多少。2、在常量缓冲区进行申明3、在顶点着色器中&#xff0c;在进行其他坐标转化之前&#xff0c;对模型顶点本地空间下的坐标进行转化4、我们来看看效果 方…

Linux宝塔面板本地部署Discuz论坛发布到公网访问【无需公网IP】

文章目录 前言1.安装基础环境2.一键部署Discuz3.安装cpolar工具4.配置域名访问Discuz5.固定域名公网地址6.配置Discuz论坛 前言 Crossday Discuz! Board&#xff08;以下简称 Discuz!&#xff09;是一套通用的社区论坛软件系统&#xff0c;用户可以在不需要任何编程的基础上&a…

产品需求分析师的职责内容(合集)

产品需求分析师的职责内容1 职责&#xff1a; 1、根据公司战略规划&#xff0c;负责妇产科相关平台产品的中长期规划; 2、组织需求调研、收集、分析、整理、提炼、用户的需求&#xff0c;分析形成可行性研究报告; 3、深入挖掘产品需求&#xff0c;管理用户及公司内部业务需求&a…

20V升26V 600mA升压型LED驱动芯片,PWM调光芯片-AH1160

AH1160是一个功能强大的升压型LED驱动芯片&#xff0c;专为需要精确控制LED亮度的PWM调光应用而设计。它可将20V输入电压升压至26V&#xff0c;同时提供稳定的600mA电流输出&#xff0c;适用于各种LED照明设备。 芯片特点&#xff1a; 1. 输入电压范围&#xff1a;AH1160可在…

6个免费设计资源站,设计师们赶紧收藏!

本期给大家分享5个免费的设计资源站&#xff0c;设计师必备的设计设计神奇&#xff0c;绝对能帮助你在工作中事半功倍&#xff0c;赶紧收藏吧~ 1、菜鸟图库 https://www.sucai999.com/?vNTYwNDUx 菜鸟图库是我推荐过很多次的网站&#xff0c;主要是站内素材多&#xff0c;像…
最新文章