PageHelper分页

PageHelper是Mybatis的一个分页插件,通过它可以完成sql语句的分页查询,不用在sql中手动添加limit参数。

 

使用PageHelper需要导入maven依赖:

<!-- pagehelper 分页插件 -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>

application.yaml中配置:

pagehelper:
  helperDialect: mysql
  supportMethodsArguments: false
  params: count=countSql

使用方式:

PageHelper.startPage(1, 10);

第一个参数是页码,第二个参数是每页条数。调用它之后,后面紧跟的下一条sql语句则会进行分页查询,但只会分页一次,后面的查询则不会进行分页,除非再次调用它。

 

下面看下源码是如何完成分页逻辑的:

1、MyBatis插件的加载流程:

复制代码
// Configuration.java
private void parseConfiguration(XNode root) {
    try {
        ...
        this.pluginElement(root.evalNode("plugins"));
        ...
    } catch (Exception var3) {
        throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + var3, var3);
    }
}

private void pluginElement(XNode parent) throws Exception {
    if (parent != null) {
        Iterator var2 = parent.getChildren().iterator();
        while(var2.hasNext()) {
            XNode child = (XNode)var2.next();
            String interceptor = child.getStringAttribute("interceptor");
            Properties properties = child.getChildrenAsProperties();
            Interceptor interceptorInstance = (Interceptor)this.resolveClass(interceptor).getDeclaredConstructor().newInstance();
            interceptorInstance.setProperties(properties);
            this.configuration.addInterceptor(interceptorInstance);
        }
    }
}
复制代码

MyBatis在解析配置文件时如果发现配置了插件,则会通过反射创建该插件对象,然后保存到Configuration的interceptorChain对象中

复制代码
// Configuration.java
public void addInterceptor(Interceptor interceptor) {
    this.interceptorChain.addInterceptor(interceptor);
}

// InterceptorChain.java
private final List<Interceptor> interceptors = new ArrayList();

public void addInterceptor(Interceptor interceptor) {
    this.interceptors.add(interceptor);
}
复制代码

可以看到InterceptorChain中的interceptors对象是一个List,里面保存着所有的插件对象。

 

2、插件的拦截过程

Mybatis中sql语句的调用是通过Executor对象的,而Executor对象的创建是在Configuration类中:

复制代码
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? this.defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Object 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 (this.cacheEnabled) {
        executor = new CachingExecutor((Executor)executor);
    }
    Executor executor = (Executor)this.interceptorChain.pluginAll(executor);
    return executor;
}
复制代码

可以看到,当初始化完成Executor之后,会调用interceptorChain的pluginAll()方法

// InterceptorChain.java
public Object pluginAll(Object target) {
    Interceptor interceptor;
    for(Iterator var2 = this.interceptors.iterator(); var2.hasNext(); target = interceptor.plugin(target)) {
        interceptor = (Interceptor)var2.next();
    }
    return target;
}

pluginAll方法中则是遍历所有的插件,然后调用其plugin()方法

复制代码
// Interceptor.java
default Object plugin(Object target) {
    return Plugin.wrap(target, this);
}

// Plugin.java
public static Object wrap(Object target, Interceptor interceptor) {
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    return interfaces.length > 0 ? Proxy.newProxyInstance(type.getClassLoader(), interfaces, new Plugin(target, interceptor, signatureMap)) : target;
}

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        Set<Method> methods = (Set)this.signatureMap.get(method.getDeclaringClass());
        return methods != null && methods.contains(method) ? this.interceptor.intercept(new Invocation(this.target, method, args)) : method.invoke(this.target, args);
    } catch (Exception var5) {
        throw ExceptionUtil.unwrapThrowable(var5);
    }
}
复制代码

在plugin()方法中则会对其进行jdk的动态代理,最后返回代理对象。之后当调用代理对象的方法时,则会进入invoke()方法中,如果是指定拦截的方法,则调用interceptor的intercept()方法对象进行拦截,拦截方法的参数为Invocation对象,第一个参数是Executor对象,第二个参数是拦截的方法对象, 第三个则是参数列表。

复制代码
// Plugin.java
private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) {
    Intercepts interceptsAnnotation = (Intercepts)interceptor.getClass().getAnnotation(Intercepts.class);
    if (interceptsAnnotation == null) {
        throw new PluginException("No @Intercepts annotation was found in interceptor " + interceptor.getClass().getName());
    } else {
        Signature[] sigs = interceptsAnnotation.value();
        Map<Class<?>, Set<Method>> signatureMap = new HashMap();
        Signature[] var4 = sigs;
        int var5 = sigs.length;
        for(int var6 = 0; var6 < var5; ++var6) {
            Signature sig = var4[var6];
            Set<Method> methods = (Set)MapUtil.computeIfAbsent(signatureMap, sig.type(), (k) -> {
                return new HashSet();
            });
            try {
                Method method = sig.type().getMethod(sig.method(), sig.args());
                methods.add(method);
            } catch (NoSuchMethodException var10) {
                throw new PluginException("Could not find method on " + sig.type() + " named " + sig.method() + ". Cause: " + var10, var10);
            }
        }
        return signatureMap;
    }
}
复制代码

getSignatureMap()方法会先得到拦截器上标注的注解对象Intercepts,然后解析该注解的值,将属于同一个type的method放到一个Set中进行维护

复制代码
// PageInterceptor.java
@Intercepts({@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}
)})
复制代码
// Executor.java
<E> List<E> query(MappedStatement var1, Object var2, RowBounds var3, ResultHandler var4, CacheKey var5, BoundSql var6) throws SQLException;
<E> List<E> query(MappedStatement var1, Object var2, RowBounds var3, ResultHandler var4) throws SQLException;

PageInterceptor,Map的key就是Executor.class,而Map的value则是一个Set,存储着Executor.query(MappedStatemen, Object, RowBounds, ResultHandler)和Executor.query(MappedStatement,Object,RowBounds,ResultHandler,CacheKey,BoundSql)两个Method对象。之后,当通过反射调用这两个方法时就会被拦截,调用拦截器的intercept()方法

 

3、PageInterceptor的加载

PageInterceptor拦截器的加载是在PageHelperAutoConfiguration中:

复制代码
// PageHelperAutoConfiguration.java
public void afterPropertiesSet() throws Exception {
    PageInterceptor interceptor = new PageInterceptor();
    interceptor.setProperties(this.properties); // 后面有
    Iterator var2 = this.sqlSessionFactoryList.iterator();
    while(var2.hasNext()) {
        SqlSessionFactory sqlSessionFactory = (SqlSessionFactory)var2.next();
        org.apache.ibatis.session.Configuration configuration = sqlSessionFactory.getConfiguration();
        if (!this.containsInterceptor(configuration, interceptor)) {
            configuration.addInterceptor(interceptor);
        }
    }
}
复制代码

在这里创建了PageInterceptor对象,然后遍历所有的sqlSessionFactory,将interceptor添加到了每个sqlSessionFactory的configuration对象中。

 

4、分页流程

当调用了PageHelper.startPage(1, 10)进行分页后,实际上会调用其父类PageMethod的startPage()方法

// PageHelper.java
public class PageHelper extends PageMethod implements Dialect, BoundSqlInterceptor.Chain

之后会调用几个重载的startPage()方法,直到最多参数的这个

复制代码
// PageMethod.java
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
    Page<E> page = new Page(pageNum, pageSize, count);
    page.setReasonable(reasonable);
    page.setPageSizeZero(pageSizeZero);
    Page<E> oldPage = getLocalPage();
    if (oldPage != null && oldPage.isOrderByOnly()) {
        page.setOrderBy(oldPage.getOrderBy());
    }
    setLocalPage(page);
    return page;
}

protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal();
protected static void setLocalPage(Page page) {
    LOCAL_PAGE.set(page);
}
复制代码

在这里将一些和分页参数的属性保存到了Page对象中,之后将page对象设置到和当前线程相关的threadlocal中。

复制代码
private Page(int pageNum, int pageSize, boolean count, Boolean reasonable) {
    super(0);
    this.stackTrace = PageInterceptor.isDebug() ? StackTraceUtil.current() : null;
    this.count = true;
    if (pageNum == 1 && pageSize == Integer.MAX_VALUE) {
        this.pageSizeZero = true;
        pageSize = 0;
    }
    this.pageNum = pageNum;
    this.pageSize = pageSize;
    this.count = count;
    this.calculateStartAndEndRow();
    this.setReasonable(reasonable);
}

private void calculateStartAndEndRow() {
    this.startRow = this.pageNum > 0 ? (long)((this.pageNum - 1) * this.pageSize) : 0L;
    this.endRow = this.startRow + (long)(this.pageSize * (this.pageNum > 0 ? 1 : 0));
}
复制代码

calculateStartAndEndRow()方法中初始化startRow。

 

当调用了Executor的query()方法后,就会被PageInterceptor进行拦截,调用其intercept()方法:

复制代码
public Object intercept(Invocation invocation) throws Throwable {
    try {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement)args[0];
        Object parameter = args[1];
        RowBounds rowBounds = (RowBounds)args[2];
        ResultHandler resultHandler = (ResultHandler)args[3];
        Executor executor = (Executor)invocation.getTarget();
        CacheKey cacheKey;
        BoundSql boundSql;
        if (args.length == 4) {
            boundSql = ms.getBoundSql(parameter);
            cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
        } else {
            cacheKey = (CacheKey)args[4];
            boundSql = (BoundSql)args[5];
        }
        this.checkDialectExists();
        if (this.dialect instanceof BoundSqlInterceptor.Chain) {
            boundSql = ((BoundSqlInterceptor.Chain)this.dialect).doBoundSql(Type.ORIGINAL, boundSql, cacheKey);
        }
        List resultList;
        if (!this.dialect.skip(ms, parameter, rowBounds)) {
            this.debugStackTraceLog();
            if (this.dialect.beforeCount(ms, parameter, rowBounds)) {
                Long count = this.count(executor, ms, parameter, rowBounds, (ResultHandler)null, boundSql);
                if (!this.dialect.afterCount(count, parameter, rowBounds)) {
                    Object var12 = this.dialect.afterPage(new ArrayList(), parameter, rowBounds);
                    return var12;
                }
            }
            resultList = ExecutorUtil.pageQuery(this.dialect, executor, ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
        } else {
            resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
        }
        Object var16 = this.dialect.afterPage(resultList, parameter, rowBounds);
        return var16;
    } finally {
        if (this.dialect != null) {
            this.dialect.afterAll();
        }
    }
}
复制代码

和分页相关的核心逻辑在于ExecutorUtil.pageQuery()方法中:

复制代码
public static <E> List<E> pageQuery(Dialect dialect, Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql, CacheKey cacheKey) throws SQLException {
    if (!dialect.beforePage(ms, parameter, rowBounds)) {
        return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, boundSql);
    } else {
        parameter = dialect.processParameterObject(ms, parameter, boundSql, cacheKey);
        String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, cacheKey);
        BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);
        Map<String, Object> additionalParameters = getAdditionalParameter(boundSql);
        Iterator var12 = additionalParameters.keySet().iterator();
        while(var12.hasNext()) {
            String key = (String)var12.next();
            pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
        }
        if (dialect instanceof BoundSqlInterceptor.Chain) {
            pageBoundSql = ((BoundSqlInterceptor.Chain)dialect).doBoundSql(Type.PAGE_SQL, pageBoundSql, cacheKey);
        }
        return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, pageBoundSql);
    }
}
复制代码

参数中的dialect是PageHelper对象,其初始化在:

复制代码
// PageInterceptor.java
this.default_dialect_class = "com.github.pagehelper.PageHelper";
public void setProperties(Properties properties) {
    ...
    String dialectClass = properties.getProperty("dialect");
    if (StringUtil.isEmpty(dialectClass)) {
        dialectClass = this.default_dialect_class;
    }
    try {
        Class<?> aClass = Class.forName(dialectClass);
        this.dialect = (Dialect)aClass.newInstance();
    } catch (Exception var7) {
        throw new PageException(var7);
    }
    this.dialect.setProperties(properties);
}

// PageHelper.java
public void setProperties(Properties properties) {
    ...
    this.autoDialect = new PageAutoDialect();
    ...
    this.autoDialect.setProperties(properties);
    ...
}

// PageAutoDialect.java
public void setProperties(Properties properties) {
    ...
    String dialect = properties.getProperty("helperDialect");
    String runtimeDialect = properties.getProperty("autoRuntimeDialect");
    if (StringUtil.isNotEmpty(runtimeDialect) && "TRUE".equalsIgnoreCase(runtimeDialect)) {
        this.autoDialect = false;
        this.properties = properties;
    } else if (StringUtil.isEmpty(dialect)) {
        this.autoDialect = true;
        this.properties = properties;
    } else {
        this.autoDialect = false;
        this.delegate = instanceDialect(dialect, properties);
    }
}

public static AbstractHelperDialect instanceDialect(String dialectClass, Properties properties) {
    if (StringUtil.isEmpty(dialectClass)) {
        throw new PageException("使用 PageHelper 分页插件时,必须设置 helper 属性");
    } else {
        AbstractHelperDialect dialect;
        try {
            Class sqlDialectClass = resloveDialectClass(dialectClass);
            if (!AbstractHelperDialect.class.isAssignableFrom(sqlDialectClass)) {
                throw new PageException("使用 PageHelper 时,方言必须是实现 " + AbstractHelperDialect.class.getCanonicalName() + " 接口的实现类!");
            }
            dialect = (AbstractHelperDialect)sqlDialectClass.newInstance();
        } catch (Exception var4) {
            throw new PageException("初始化 helper [" + dialectClass + "]时出错:" + var4.getMessage(), var4);
        }
        dialect.setProperties(properties);
        return dialect;
    }
}

public static Class resloveDialectClass(String className) throws Exception {
    return dialectAliasMap.containsKey(className.toLowerCase()) ? (Class)dialectAliasMap.get(className.toLowerCase()) : Class.forName(className);
}
复制代码

PageHelper的setProperties()方法中根据helperDialect的值创建delegate对象。从resloveDialectClass()方法中可以看出是从dialectAliasMap根据name获取到Class对象,之后再通过反射创建其对象。

复制代码
// PageAutoDialect.java
static {
    ...
    registerDialectAlias("mysql", MySqlDialect.class);
    ...
}

public static void registerDialectAlias(String alias, Class<? extends Dialect> dialectClass) {
    dialectAliasMap.put(alias, dialectClass);
}
复制代码

最后delegate实际上就是MySqlDialect对象。

 

回到前面,所以dialect.processParameterObject()实际就是调用PageHelper的processParameterObject():

// PageHelper.java
public Object processParameterObject(MappedStatement ms, Object parameterObject, BoundSql boundSql, CacheKey pageKey) {
    return this.autoDialect.getDelegate().processParameterObject(ms, parameterObject, boundSql, pageKey);
}

PageHelper的autoDialect对象类型是PageAutoDialect,其getDelegate()方法返回的就是MySqlDialect对象。

复制代码
// AbstractHelperDialect.java
public Object processParameterObject(MappedStatement ms, Object parameterObject, BoundSql boundSql, CacheKey pageKey) {
    Page page = this.getLocalPage();
    if (page.isOrderByOnly()) {
        return parameterObject;
    } else {
        Map<String, Object> paramMap = null;
        if (parameterObject == null) {
            paramMap = new HashMap();
        } else if (parameterObject instanceof Map) {
            paramMap = new HashMap();
            paramMap.putAll((Map)parameterObject);
        } else {
            paramMap = new HashMap();
            if (ms.getSqlSource() instanceof ProviderSqlSource) {
                String[] providerMethodArgumentNames = ExecutorUtil.getProviderMethodArgumentNames((ProviderSqlSource)ms.getSqlSource());
                if (providerMethodArgumentNames != null && providerMethodArgumentNames.length == 1) {
                    paramMap.put(providerMethodArgumentNames[0], parameterObject);
                    paramMap.put("param1", parameterObject);
                }
            }
            boolean hasTypeHandler = ms.getConfiguration().getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass());
            MetaObject metaObject = MetaObjectUtil.forObject(parameterObject);
            if (!hasTypeHandler) {
                String[] var9 = metaObject.getGetterNames();
                int var10 = var9.length;
                for(int var11 = 0; var11 < var10; ++var11) {
                    String name = var9[var11];
                    paramMap.put(name, metaObject.getValue(name));
                }
            }
            if (boundSql.getParameterMappings() != null && boundSql.getParameterMappings().size() > 0) {
                Iterator var14 = boundSql.getParameterMappings().iterator();
                ParameterMapping parameterMapping;
                String name;
                do {
                    do {
                        do {
                            do {
                                if (!var14.hasNext()) {
                                    return this.processPageParameter(ms, paramMap, page, boundSql, pageKey);
                                }
                                parameterMapping = (ParameterMapping)var14.next();
                                name = parameterMapping.getProperty();
                            } while(name.equals("First_PageHelper"));
                        } while(name.equals("Second_PageHelper"));
                    } while(paramMap.get(name) != null);
                } while(!hasTypeHandler && !parameterMapping.getJavaType().equals(parameterObject.getClass()));
                paramMap.put(name, parameterObject);
            }
        }
        return this.processPageParameter(ms, paramMap, page, boundSql, pageKey);
    }
}

// MySqlDialect.java
public Object processPageParameter(MappedStatement ms, Map<String, Object> paramMap, Page page, BoundSql boundSql, CacheKey pageKey) {
    paramMap.put("First_PageHelper", page.getStartRow());
    paramMap.put("Second_PageHelper", page.getPageSize());
    pageKey.update(page.getStartRow());
    pageKey.update(page.getPageSize());
    if (boundSql.getParameterMappings() != null) {
        List<ParameterMapping> newParameterMappings = new ArrayList(boundSql.getParameterMappings());
        if (page.getStartRow() == 0L) {
            newParameterMappings.add((new ParameterMapping.Builder(ms.getConfiguration(), "Second_PageHelper", Integer.TYPE)).build());
        } else {
            newParameterMappings.add((new ParameterMapping.Builder(ms.getConfiguration(), "First_PageHelper", Long.TYPE)).build());
            newParameterMappings.add((new ParameterMapping.Builder(ms.getConfiguration(), "Second_PageHelper", Integer.TYPE)).build());
        }
        MetaObject metaObject = MetaObjectUtil.forObject(boundSql);
        metaObject.setValue("parameterMappings", newParameterMappings);
    }
    return paramMap;
}
复制代码

简单来说上面的方法主要做的就是将查询参数放到Map中,并且将First_PageHelper设置为startRow的值,Second_PageHelper设置为每页记录条数。

 

之后调用dialect.getPageSql()对sql语句进行修改,添加limit语句:

复制代码
// AbstractHelperDialect.java
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
    String sql = boundSql.getSql();
    Page page = this.getLocalPage();
    String orderBy = page.getOrderBy();
    if (StringUtil.isNotEmpty(orderBy)) {
        pageKey.update(orderBy);
        sql = OrderByParser.converToOrderBySql(sql, orderBy, this.jSqlParser);
    }
    return page.isOrderByOnly() ? sql : this.getPageSql(sql, page, pageKey);
}
// MySqlDialect.java
public String getPageSql(String sql, Page page, CacheKey pageKey) {
    StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
    sqlBuilder.append(sql);
    if (page.getStartRow() == 0L) {
        sqlBuilder.append("\n LIMIT ? ");
    } else {
        sqlBuilder.append("\n LIMIT ?, ? ");
    }
    return sqlBuilder.toString();
}
复制代码

MySqlDialect的getPageSql()对sql语句进行修改。之后则是通过修改后的sql语句以及对应参数调用executor.query()进行查询得到最终结果。

 

当执行完查询后,则会在PageInterceptor#interceptor()方法中调用PageHelper的afterAll():

复制代码
// PageInterceptor.java
public Object intercept(Invocation invocation) throws Throwable {
    try {
        ...
    } finally {
        if (this.dialect != null) {
            this.dialect.afterAll();
        }
    }
}

// PageHelper.java
public void afterAll() {
    AbstractHelperDialect delegate = this.autoDialect.getDelegate();
    if (delegate != null) {
        delegate.afterAll();
        this.autoDialect.clearDelegate();
    }
    clearPage();
}

// PageMethod.java
public static void clearPage() {
    LOCAL_PAGE.remove();
}
复制代码

可以看到最终会将threadlocal中的page数据清除掉,这样下一个查询不会被分页。

 

如果在查询前没有调用PageHelper.startPage()方法:

复制代码
// PageInterceptor.java
public Object intercept(Invocation invocation) throws Throwable {
    try {
        ...
        if (!this.dialect.skip(ms, parameter, rowBounds)) {
            ...
        } else {
            resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
        }
        ...
        
    } finally {
        ...
    }
}
// PageHelper.java
public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
    Page page = this.pageParams.getPage(parameterObject, rowBounds);
    if (page == null) {
        return true;
    } else {
        if (StringUtil.isEmpty(page.getCountColumn())) {
            page.setCountColumn(this.pageParams.getCountColumn());
        }
        this.autoDialect.initDelegateDialect(ms, page.getDialectClass());
        return false;
    }
}

// PageParams.java
public Page getPage(Object parameterObject, RowBounds rowBounds) {
    Page page = PageHelper.getLocalPage();
    if (page == null) {
        if (rowBounds != RowBounds.DEFAULT) {
.                      ...
        } else if (parameterObject instanceof IPage || this.supportMethodsArguments) {
            ...
        }
        if (page == null) {
            return null;
        }
        PageHelper.setLocalPage(page);
    }
    ...
    return page;
}
复制代码

由于没有在threadlocal中设置Page,此时获取到的page为空,导致skip()返回true,这样就会执行原来的sql语句查询。

 

 

posted @   krystalZ2021  阅读(97)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
点击右上角即可分享
微信分享提示