MyBatis 游标 Cursor 源码深度解析:流式查询的延迟取数实现原理
2026/9/20 22:36:32 网站建设 项目流程
  • 文档
  • 教程
  • 知识库

【免费下载链接】source-code-hunter

😱 从源码层面,剖析挖掘互联网行业主流技术的底层实现原理,为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶,Mybatis、Netty、Dubbo 框架,及 Redis、Tomcat 中间件等

项目地址:https://gitcode.com/doocs/source-code-hunter
点击查看免费下载

本文基于 doocs/source-code-hunter 仓库中 docs/Mybatis/核心处理层/Mybatis-Cursor.md 一文展开,结合仓库内 MyBatis 核心处理层其余组件(StatementHandler、Executor、SqlSession、MapperMethod)的源码笔记,从org.apache.ibatis.cursor包出发,完整梳理 MyBatis 游标(Cursor)的接口设计、默认实现、状态机流转与懒加载取数机制。读完本文,你将理解:为什么 Mapper 方法返回Cursor<T>时数据不是一次全部加载进内存、游标内部如何配合ResultSet逐行取数、RowBounds分页与游标如何协同,以及在实际项目中安全使用游标流式查询的注意事项。

一、为什么要引入 Cursor:从 List 到流式查询

MyBatis 常规的查询方法(如selectList)会将数据库返回的所有记录一次性映射为对象列表,再整体返回给调用方。当查询结果集很大(例如千万级数据导出、全表扫描)时,一次性加载所有对象会造成巨大的内存压力,甚至触发 OOM。

为了解决这一问题,MyBatis 提供了游标(Cursor)机制:查询结果以"流"的形式返回,调用方每迭代一次,才从数据库ResultSet中取出一行并完成映射。这样内存中同一时刻只保留少量对象,真正做到了按需加载、边读边取

从源码结构看,游标机制贯穿 MyBatis 的接口层到核心处理层,是一条完整的调用链(详见下文第六节),而这一切的起点,就是org.apache.ibatis.cursor.Cursor接口。

二、Cursor 接口:一个既是迭代器又需要关闭的"游标"

源码位置:org.apache.ibatis.cursor.Cursor

public interface Cursor<T> extends Closeable, Iterable<T> { /** * 游标开始从数据库获取数据,返回true,反之false * * @return true if the cursor has started to fetch items from database. */ boolean isOpen(); /** * 数据库元素都被获取,返回true,反之false * * @return true if the cursor is fully consumed and has returned all elements matching the query. */ boolean isConsumed(); /** * 获取数据索引,从0开始,没有返回-1 * Get the current item index. The first item has the index 0. * * @return -1 if the first cursor item has not been retrieved. The index of the current item retrieved. */ int getCurrentIndex(); }

这个接口的定义非常精炼,从继承关系就能读出设计意图:

  • 继承Iterable<T>:说明它是一个迭代器,调用方可以通过iterator()/for-each方式逐个消费数据;
  • 继承Closeable:说明它背后持有需要释放的资源——底层数据库ResultSet与连接,使用完毕后必须关闭;
  • isOpen():返回当前游标是否已经开始从数据库取数(即底层ResultSet是否已被消费);
  • isConsumed():返回游标是否已被完全消费(所有符合查询条件的数据都已取出);
  • getCurrentIndex():返回当前取到的数据索引,第一条数据的索引为 0,如果第一条数据尚未被取出则返回 -1。

这三个方法为调用方提供了一种"状态自检"能力:在迭代过程中可以随时判断游标处于何种阶段,进而决定是继续消费还是提前关闭。

三、DefaultCursor:Cursor 接口的默认实现

MyBatis 为Cursor接口提供了唯一的标准实现类DefaultCursor<T>(位于org.apache.ibatis.cursor.defaults包)。它内部聚合了结果集处理所需的一系列组件,是理解游标机制的"主战场"。

public class DefaultCursor<T> implements Cursor<T> { /** * 对象包装结果处理类 */ protected final ObjectWrapperResultHandler<T> objectWrapperResultHandler = new ObjectWrapperResultHandler<>(); // ResultSetHandler stuff /** * ResultSet 处理器 */ private final DefaultResultSetHandler resultSetHandler; /** * 结果映射 */ private final ResultMap resultMap; /** * ResultSet 包装对象 */ private final ResultSetWrapper rsw; /** * 分页的 */ private final RowBounds rowBounds; /** * 游标的迭代器 */ private final CursorIterator cursorIterator = new CursorIterator(); /** * 游标开启判断 */ private boolean iteratorRetrieved; /** * 游标状态,默认是创建未使用 */ private CursorStatus status = CursorStatus.CREATED; /** * 分页索引,默认-1 */ private int indexWithRowBound = -1; /** * 构造方法 * * @param resultSetHandler * @param resultMap * @param rsw * @param rowBounds */ public DefaultCursor(DefaultResultSetHandler resultSetHandler, ResultMap resultMap, ResultSetWrapper rsw, RowBounds rowBounds) { this.resultSetHandler = resultSetHandler; this.resultMap = resultMap; this.rsw = rsw; this.rowBounds = rowBounds; } // ... 省略方法实现,见下文分节 }

3.1 核心字段的职责

字段类型职责
objectWrapperResultHandlerObjectWrapperResultHandler<T>包装结果处理器,负责承接ResultSetHandler映射出的单行结果对象
resultSetHandlerDefaultResultSetHandlerMyBatis 核心结果集处理器,负责将ResultSet当前行映射为业务对象
resultMapResultMap结果映射配置,指明当前行如何映射为对象
rswResultSetWrapper对 JDBCResultSet的包装,提供列信息与便捷访问
rowBoundsRowBounds逻辑分页参数,记录 offset(偏移量)与 limit(限额)
cursorIteratorCursorIterator游标内部迭代器,是调用方iterator()返回的对象
iteratorRetrievedboolean标记迭代器是否已被获取(一个游标只能取出一次迭代器)
statusCursorStatus游标当前状态(见 3.2 状态机)
indexWithRowBoundint结合分页的取数索引,默认 -1

从这些字段可以看出,DefaultCursor并不直接持有ResultSet,而是通过ResultSetWrapper rsw间接访问 JDBC 结果集;真正的行映射工作也并非由游标自己完成,而是委托给DefaultResultSetHandler。游标本身只负责"何时取、取到后如何流转"。

3.2 游标状态机:CREATED → OPEN → CONSUMED / CLOSED

DefaultCursor内部通过一个私有枚举CursorStatus管理游标的生命周期,这是理解整个类行为的关键:

/** * 游标的状态 */ private enum CursorStatus { /** * 新创建的游标, ResultSet 还没有使用过 * A freshly created cursor, database ResultSet consuming has not started. */ CREATED, /** * 游标使用过, ResultSet 被使用 * A cursor currently in use, database ResultSet consuming has started. */ OPEN, /** * 游标关闭, 可能没有被消费完全 * A closed cursor, not fully consumed. */ CLOSED, /** * 游标彻底消费完毕, 关闭了 * A fully consumed cursor, a consumed cursor is always closed. */ CONSUMED }

四种状态的含义如下:

状态含义触发时机
CREATED刚创建,ResultSet尚未开始消费DefaultCursor实例化时(默认值)
OPEN使用中,ResultSet已被消费每次调用fetchNextObjectFromDatabase()取数前
CLOSED已关闭,但可能未消费完全调用close()方法时
CONSUMED完全消费完毕(已隐含关闭)取数时发现没有更多数据,或达到 limit 上限时

isOpen()isConsumed()正是对status的简单判断:

@Override public boolean isOpen() { return status == CursorStatus.OPEN; } @Override public boolean isConsumed() { return status == CursorStatus.CONSUMED; }

isClosed()是内部私有方法,CLOSEDCONSUMED均视为已关闭:

private boolean isClosed() { return status == CursorStatus.CLOSED || status == CursorStatus.CONSUMED; }

从源码注释可以看出设计意图:CONSUMED状态的游标必然是关闭的("a consumed cursor is always closed"),而CLOSED状态的游标则可能是被调用方提前中断、并未消费完全。

3.3 iterator():一个游标只能被迭代一次

游标通过iterator()方法对外暴露迭代器,MyBatis 对它的使用有严格的限制:

@Override public Iterator<T> iterator() { // 是否获取过 if (iteratorRetrieved) { throw new IllegalStateException("Cannot open more than one iterator on a Cursor"); } // 是否关闭 if (isClosed()) { throw new IllegalStateException("A Cursor is already closed."); } iteratorRetrieved = true; return cursorIterator; }

这里有两个关键约束:

  1. 单次迭代iteratorRetrieved标记一旦置为true,再次调用iterator()会抛出IllegalStateException("Cannot open more than one iterator on a Cursor")。这是因为底层ResultSet是单向、不可回退的,不可能支持多次遍历;
  2. 关闭后不可迭代:若游标已经处于CLOSEDCONSUMED状态,同样抛出IllegalStateException("A Cursor is already closed.")

3.4 close():关闭底层 ResultSet

close()方法体现了Cursor继承Closeable的初衷——释放底层数据库资源:

@Override public void close() { // 判断是否关闭 if (isClosed()) { return; } ResultSet rs = rsw.getResultSet(); try { if (rs != null) { rs.close(); } } catch (SQLException e) { // ignore } finally { // 设置游标状态 status = CursorStatus.CLOSED; } }

实现要点:

  • 幂等设计:如果已经关闭则直接返回,重复调用close()是安全的;
  • 真正关闭的是ResultSetWrapper内部持有的 JDBCResultSet
  • SQLException被显式忽略(// ignore),避免关闭资源时的异常干扰业务逻辑;
  • 无论成功与否,finally中都会将状态置为CLOSED

四、游标核心取数逻辑:懒加载与逐行映射

DefaultCursor的精华在于它的取数过程:只有迭代器真正要求"下一个"时,才去数据库取一行。这一行为由CursorIteratorObjectWrapperResultHandler协同完成。

4.1 ObjectWrapperResultHandler:单行结果承接器

ObjectWrapperResultHandlerDefaultCursor的受保护静态内部类,实现了ResultHandler<T>接口:

/** * 对象处理结果的包装类 * @param <T> */ protected static class ObjectWrapperResultHandler<T> implements ResultHandler<T> { /** * 数据结果 */ protected T result; /** * 是否null */ protected boolean fetched; /** * 从{@link ResultContext} 获取结果对象 * @param context */ @Override public void handleResult(ResultContext<? extends T> context) { this.result = context.getResultObject(); context.stop(); fetched = true; } }

它的工作方式非常巧妙:

  • handleResult(ResultContext)ResultSetHandler回调,从中取出当前行的映射结果对象存入result字段;
  • 紧接着调用context.stop()立即终止本次结果集处理——这正是"每次只处理一行"的关键:MyBatis 常规的结果集处理会遍历完整个ResultSet,而游标模式通过stop()让处理过程在取完一行后立刻停下;
  • fetched置为true,作为"本次是否成功取到一行"的标志位,供外层判断。

4.2 fetchNextObjectFromDatabase():取一行的完整流程

这是游标取数的"发动机"方法:

/** * 从数据库获取数据 * @return */ protected T fetchNextObjectFromDatabase() { if (isClosed()) { return null; } try { objectWrapperResultHandler.fetched = false; // 游标状态设置 status = CursorStatus.OPEN; if (!rsw.getResultSet().isClosed()) { // 处理数据结果放入,objectWrapperResultHandler resultSetHandler.handleRowValues(rsw, resultMap, objectWrapperResultHandler, RowBounds.DEFAULT, null); } } catch (SQLException e) { throw new RuntimeException(e); } // 获取处理结果 T next = objectWrapperResultHandler.result; // 结果不为空 if (objectWrapperResultHandler.fetched) { // 索引+1 indexWithRowBound++; } // No more object or limit reached // 如果没有数据, 或者 当前读取条数= 偏移量+限额量 if (!objectWrapperResultHandler.fetched || getReadItemsCount() == rowBounds.getOffset() + rowBounds.getLimit()) { // 关闭游标 close(); status = CursorStatus.CONSUMED; } // 设置结果为null objectWrapperResultHandler.result = null; return next; }

逐行拆解这个过程:

  1. 关闭检查:若游标已关闭直接返回null
  2. 重置标志位:将fetched置为false,准备新一轮取数;
  3. 状态流转status置为OPEN,表示已开始消费ResultSet
  4. 委托取数:在ResultSet未关闭的前提下,调用resultSetHandler.handleRowValues(...)处理当前行,结果经由ObjectWrapperResultHandler.handleResult()落入result字段,并因context.stop()只处理这一行;
  5. 结果搬移next取出本次结果;若fetched == true说明取到了有效行,indexWithRowBound自增;
  6. 终止条件判断:当满足以下任一条件时关闭并置为CONSUMED
    • !objectWrapperResultHandler.fetchedResultSet已无更多数据(handleRowValues没有触发任何回调);
    • getReadItemsCount() == rowBounds.getOffset() + rowBounds.getLimit():读取条数已达到"偏移量 + 限额量",即配合RowBounds取够了目标行数;
  7. 清空承接器:将result置回null,避免脏数据残留,同时保证下一次hasNext()能依据fetched正确判断是否需要再取数。

注意这里的getReadItemsCount()定义:

/** * 下一个索引 * @return */ private int getReadItemsCount() { return indexWithRowBound + 1; }

即"已读取条数 = 当前索引 + 1"。

4.3 CursorIterator:对外暴露的懒加载迭代器

CursorIteratorDefaultCursor的内部类,实现了java.util.Iterator<T>,调用方拿到的就是它:

/** * 游标迭代器 */ protected class CursorIterator implements Iterator<T> { /** * 下一个数据 * Holder for the next object to be returned. */ T object; /** * 下一个的索引 * Index of objects returned using next(), and as such, visible to users. */ int iteratorIndex = -1; /** * 是否有下一个值 * @return */ @Override public boolean hasNext() { if (!objectWrapperResultHandler.fetched) { object = fetchNextUsingRowBound(); } return objectWrapperResultHandler.fetched; } /** * 下一个值 * @return */ @Override public T next() { // Fill next with object fetched from hasNext() T next = object; if (!objectWrapperResultHandler.fetched) { next = fetchNextUsingRowBound(); } if (objectWrapperResultHandler.fetched) { objectWrapperResultHandler.fetched = false; object = null; iteratorIndex++; return next; } throw new NoSuchElementException(); } /** * 不可执行抛出异常 */ @Override public void remove() { throw new UnsupportedOperationException("Cannot remove element from Cursor"); } }

这段代码体现了典型的懒加载迭代器设计(配合DefaultCursor.hasNext/next的标准 Java 迭代协议):

  • hasNext():只有当fetchedfalse(当前没有缓存结果)时才真正调用fetchNextUsingRowBound()去数据库取数,并暂存到object字段;返回值由fetched决定。因此实际的数据读取发生在hasNext()而非next()
  • next():优先复用hasNext()阶段预取的对象;若当前没有预取结果则再取一次;取到后重置fetched、清空objectiteratorIndex自增并返回;若确实无数据则抛出NoSuchElementException
  • remove():游标只读、不可删除元素,直接抛出UnsupportedOperationException

iteratorIndex是"对用户可见的索引"(自 0 开始,随next()成功调用递增),它与 3.4 节关闭的ResultSet一样,共同支撑起getCurrentIndex()的计算。

4.4 fetchNextUsingRowBound():RowBounds 偏移量处理

getCurrentIndex()与偏移量计算依赖rowBounds,而跳过偏移量的逻辑在fetchNextUsingRowBound()中:

/** * 去到真正的数据行 * @return */ protected T fetchNextUsingRowBound() { T result = fetchNextObjectFromDatabase(); while (objectWrapperResultHandler.fetched && indexWithRowBound < rowBounds.getOffset()) { result = fetchNextObjectFromDatabase(); } return result; }

实现思路:先取一行;只要"取到了数据"且"当前索引仍小于 offset",就继续取下一行,直到越过偏移量到达真正的目标起始行。也就是说,游标模式下RowBounds的 offset 是通过实际读取并丢弃前 offset 行来实现的

getCurrentIndex()的返回值为:

@Override public int getCurrentIndex() { return rowBounds.getOffset() + cursorIterator.iteratorIndex; }

即"偏移量 + 迭代器索引",这样返回给调用方的索引是相对整个结果集而言的"绝对位置",而非相对游标起点的位置。

五、状态流转全景:一次完整迭代的时序

将前面几节串联起来,一次典型的游标消费过程如下:

  1. 查询方法返回DefaultCursor实例,此时status = CREATEDiteratorRetrieved = falseindexWithRowBound = -1
  2. 调用方执行cursor.iterator(),校验通过后iteratorRetrieved = true,返回CursorIterator
  3. for-each首次触发hasNext()fetchedfalse→ 调用fetchNextUsingRowBound()fetchNextObjectFromDatabase()
    • status置为OPENresultSetHandler.handleRowValues(...)取第一行;
    • 若成功:fetched = trueindexWithRowBound递增为 0;
    • RowBounds.offset > 0:while 循环继续丢弃前几行;
    • 若已无数据或达到 limit:close()并置为CONSUMED
  4. next()返回暂存对象,iteratorIndex递增;
  5. 重复 3、4 直到hasNext()返回false(底层ResultSet耗尽或达到 limit),此时游标已自动进入CONSUMED状态;
  6. 若中途需要中断(如提前 break),调用方应主动调用cursor.close(),使状态进入CLOSED并关闭底层ResultSet

六、游标的完整调用链:从 Mapper 方法到 DefaultCursor

游标并非孤立组件,它在 MyBatis 中贯穿"接口层 → 核心处理层"。结合仓库中其他源码笔记,可以还原出完整链路:

6.1 触发点:MapperMethod 根据返回值类型分流

MyBatis 会为 Mapper 接口方法解析方法签名,判断返回值类型。在 Mybatis-MethodSignature.md 中可以看到,MethodSignature专门记录了returnsCursor标志:

/** * 返回的是否是一个游标 */ private final boolean returnsCursor; // ... this.returnsCursor = Cursor.class.equals(this.returnType);

当方法返回类型恰好是Cursor(或其子类型判断)时,returnsCursor = true。随后在MapperMethod.execute()(见 Mybatis-MapperMethod.md)中根据该标志选择执行路径:

} else if (method.returnsCursor()) { result = executeForCursor(sqlSession, args); }

即:只要 Mapper 方法声明返回Cursor<T>,MyBatis 就会走游标查询分支,而不会走selectList全量加载路径。

6.2 接口层:SqlSession.selectCursor

SqlSession接口为游标查询提供了三个重载(见 6、SqlSession组件.md):

// 除了返回值是Cursor对象,其它与selectList相同 <T> Cursor<T> selectCursor(String statement); <T> Cursor<T> selectCursor(String statement, Object parameter); <T> Cursor<T> selectCursor(String statement, Object parameter, RowBounds rowBounds);

它最终会调用ExecutorqueryCursor()方法。

6.3 核心层:Executor → StatementHandler → ResultSetHandler

在 5、Executor组件.md 中,Executor接口定义了:

<E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;

SimpleExecutor.doQueryCursor()的实现是:

@Override protected <E> Cursor<E> doQueryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds, BoundSql boundSql) throws SQLException { Configuration configuration = ms.getConfiguration(); StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, null, boundSql); Statement stmt = prepareStatement(handler, ms.getStatementLog()); return handler.<E>queryCursor(stmt); }

ReuseExecutorBatchExecutordoQueryCursor()doQuery()实现类似(BatchExecutor会先flushStatements()保证读到最新数据)。随后链路进入 4、StatementHandler.md:

  • RoutingStatementHandler.queryCursor()委托给具体策略(SimpleStatementHandler/PreparedStatementHandler);
  • PreparedStatementHandler.queryCursor()执行 SQL 后调用resultSetHandler.handleCursorResultSets(ps)
@Override public <E> Cursor<E> queryCursor(Statement statement) throws SQLException { PreparedStatement ps = (PreparedStatement) statement; ps.execute(); return resultSetHandler.handleCursorResultSets(ps); }

DefaultResultSetHandler.handleCursorResultSets()会从ResultSet构造ResultSetWrapper,并结合resultMaprowBounds创建出DefaultCursor实例返回——这正是本文第三、四节分析的对象的诞生之处。

6.4 一条完整的调用链

Mapper 接口方法 (返回 Cursor<T>) └─ MapperMethod.execute() → executeForCursor() [returnsCursor 分流] └─ SqlSession.selectCursor(statement, param, rowBounds) └─ Executor.queryCursor(ms, param, rowBounds) └─ SimpleExecutor.doQueryCursor() └─ StatementHandler.queryCursor(stmt) └─ PreparedStatementHandler.queryCursor(ps) └─ DefaultResultSetHandler.handleCursorResultSets(ps) └─ new DefaultCursor(resultSetHandler, resultMap, rsw, rowBounds) └─ 调用方 iterator() → CursorIterator └─ hasNext()/next() → fetchNextUsingRowBound() └─ fetchNextObjectFromDatabase() └─ resultSetHandler.handleRowValues() 逐行映射

七、实战使用建议与注意事项

基于上述源码机制,在实际项目中使用游标流式查询时有几个必须遵守的约定:

  1. 务必在 try-with-resources 或 finally 中关闭游标DefaultCursor持有底层的 JDBCResultSet,即使迭代完全结束会自动置为CONSUMED,中途提前中断(break、异常)时也应及时close()释放数据库资源,避免连接泄漏;
  2. 游标不可复用iterator()只能调用一次,否则抛出IllegalStateException;不要尝试对同一个游标进行多次遍历;
  3. 迭代期间保持 SqlSession 与连接存活。由于取数是懒加载的,hasNext()时才真正访问ResultSet,如果在迭代完成前关闭了SqlSession(或归还连接),后续取数将失败;
  4. RowBounds.offset有实际读取代价。游标模式不会像 SQL 分页那样在数据库层跳过前 N 行,而是逐行读取并丢弃 offset 之前的行(见 4.4 节),offset 很大时会产生额外开销;
  5. 游标只读remove()方法直接抛异常,不要尝试通过迭代器修改数据;
  6. 配合fetchSize使用效果更佳BaseStatementHandler.setFetchSize()会读取MappedStatement或全局配置的fetchSize并设置到 JDBCStatement上(见 4、StatementHandler.md),合理设置fetchSize可控制每次从数据库拉取到客户端的行数,进一步优化流式查询的内存占用。

八、小结

本文以 Mybatis-Cursor.md 为主线,从Cursor接口的三个方法出发,深入剖析了DefaultCursor的字段设计、CursorStatus四态状态机、ObjectWrapperResultHandler的单行承接机制,以及CursorIterator懒加载取数的完整流程,并结合仓库中MethodSignatureMapperMethodSqlSessionExecutorStatementHandler等源码笔记还原了游标从 Mapper 方法到DefaultCursor的完整调用链。

可以看到,MyBatis 游标的设计核心在于:将"结果集处理"从"一次全量"拆解为"逐行触发"——ResultHandlercontext.stop()中断机制 +Iterator协议 + 状态机三者配合,在保持ResultSet打开的前提下实现了真正的流式消费。理解这一机制,不仅能让你在数据导出、全表扫描等大结果集场景下写出内存安全的代码,也能更深入地体会 MyBatis 在"接口层易用性"与"底层资源可控性"之间所做的精妙权衡。

延伸阅读

  • docs/Mybatis/核心处理层/Mybatis-Cursor.md:本文主线的原始源码笔记
  • docs/Mybatis/核心处理层/Mybatis-MethodSignature.md:returnsCursor返回值类型判定
  • docs/Mybatis/核心处理层/Mybatis-MapperMethod.md:executeForCursor分流逻辑
  • docs/Mybatis/核心处理层/6、SqlSession组件.md:selectCursor接口定义与DefaultSqlSession实现
  • docs/Mybatis/核心处理层/5、Executor组件.md:Executor.queryCursor()与三种 Executor 实现
  • docs/Mybatis/核心处理层/4、StatementHandler.md:queryCursor()handleCursorResultSets()的调用
  • 文档
  • 教程
  • 知识库

【免费下载链接】source-code-hunter

😱 从源码层面,剖析挖掘互联网行业主流技术的底层实现原理,为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶,Mybatis、Netty、Dubbo 框架,及 Redis、Tomcat 中间件等

项目地址:https://gitcode.com/doocs/source-code-hunter
点击查看免费下载

相关推荐

上一篇:OpCore-Simplify:三步搞定Hackintosh配置的终极指南
下一篇:Encog多线程训练指南:充分利用多核CPU加速机器学习

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询