ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于pytest的接口自动化测试框架实践:从数据隔离到多层断言

基于pytest的接口自动化测试框架实践:从数据隔离到多层断言 1. 项目起源与整体测试思路拆解1.1 为什么项目代号叫 Test001先交代一下背景。Test001 是我最近折腾的一个接口自动化测试专项实验项目的代号。起这个名字没什么玄机纯粹是因为这是一套从零开始搭建的验证性框架用来解决我手里几个老项目回归测试效率太低的问题。001 代表这是第一版实验后续大概率会有 002、003 的迭代所以代号就从这个最朴素的编号开始。这类项目其实在很多团队里都很常见尤其是业务迭代速度加快之后手工回归的瓶颈会越来越明显。举个例子一个稍微复杂的下单流程涉及登录鉴权、库存校验、优惠计算、支付回调四五个环节纯手工点一遍至少十五分钟而且每次改动都可能引入新问题回归频率一高测试就成了整个发布流程里最拖后腿的环节。我当时的核心诉求很简单把那些稳定不变的核心链路用自动化脚本保护起来让机器代替人去做重复劳动并且这套机制要足够轻量不能为了自动化而自动化搞得维护成本比手工测试还高。所以 Test001 这个项目的定位不是一个完整的企业级测试平台而是一个够用、可扩展、能落地的最小可行框架。它围绕三件事展开接口层自动化脚本编写、测试数据构造与隔离、稳定性验证与回归报告输出。这三件事做完基本就能覆盖大多数中小型项目的日常回归需求了。1.2 核心需求解析与技术选型背后的考量这个项目的核心需求拆开来看其实就四个字验证、回归。但具体落地的时候每个字背后都有不少门道。先说验证。接口自动化验证的不是接口通不通这种表面问题而是响应结构、关键字段取值、数据一致性、异常分支处理这四层。很多新手写接口测试只检查状态码是不是 200这是典型的自欺欺人。实际项目中接口返回 200 但业务逻辑没生效的情况太常见了。比如下单接口返回了 200 和订单号但库存实际没扣减这种问题靠状态码根本发现不了。所以我的框架里断言体系分成三层状态码断言、业务字段断言、数据落库断言。每一层都有对应的检查点只有三层全过了才判定用例通过。再说回归。回归测试最怕什么最怕测试环境的数据被别人改了最怕用例之间互相影响最怕跑完一轮不知道哪些用例是真的通过还是假通过。这三个痛点直接决定了我在技术选型上的几个重要决策用 Python pytest 作为基础框架因为 Python 在测试领域的生态最成熟pytest 的 fixture 机制和参数化能力非常适合做数据隔离和批量用例管理。用 Docker 搭一套独立的 MySQL 和 Redis 作为测试环境每次跑测试前通过脚本重置数据保证用例的幂等性。用 requests 库直接发 HTTP 请求而不是引入太重型的自动化工具。对于纯接口测试来说requests 足够灵活而且方便调试和定制。这套选型思路的核心逻辑是不要为了赶时髦引入一堆不必要的组件把复杂问题简单化用最朴素的工具组合解决最实际的问题。这也是整个 Test001 项目贯穿始终的价值观。2. 环境搭建与测试框架目录设计2.1 基础环境具体版本与安装步骤先把我实际使用的环境列出来方便照着复现。组件版本用途Python3.10.11测试脚本运行环境pytest7.4.0测试用例组织与执行requests2.31.0发送 HTTP 请求pymysql1.1.0直连 MySQL 做数据校验redis4.6.0清理缓存保证数据隔离docker / docker-compose24.0.2 / 2.20.2搭建隔离的测试环境allure-pytest2.13.2生成测试报告可选安装步骤不复杂但有几个坑值得单独提醒。Python 环境建议直接用虚拟环境不要装在全局不然不同项目依赖打架的时候会非常痛苦。我的习惯是在项目根目录下执行python3 -m venv venv source venv/bin/activate pip install pytest7.4.0 requests2.31.0 pymysql1.1.0 redis4.6.0 pip install allure-pytest2.13.2 # 如果不需要报告可以跳过Docker 环境的搭建用 docker-compose 管理我写了这样一个配置文件version: 3 services: mysql: image: mysql:8.0 container_name: test001-mysql environment: MYSQL_ROOT_PASSWORD: test001_pass MYSQL_DATABASE: test001_db ports: - 3307:3306 command: --default-authentication-pluginmysql_native_password redis: image: redis:7.0 container_name: test001-redis ports: - 6380:6379注意这里我把宿主机端口映射改成了 3307 和 6380目的就是避免和本地可能已经在跑的 MySQL、Redis 冲突。很多测试环境搭建失败问题都出在端口冲突上这一点提前规划好能省不少事。2.2 框架目录结构与核心模块职责划分一个测试框架的目录结构设计直接决定了后期维护的体验。我在 Test001 里采用的是分层思路每个模块只负责一件单一的事情。test001/ ├── conf/ │ ├── __init__.py │ ├── config.py # 全局配置环境地址、超时时间、数据库连接信息 │ └── test_data.yaml # 测试数据配置文件 ├── common/ │ ├── __init__.py │ ├── client.py # 基于 requests 封装的 HTTP 客户端 │ ├── db.py # 数据库操作封装 │ ├── assert_utils.py # 自定义断言工具 │ └── log.py # 日志模块 ├── testcases/ │ ├── __init__.py │ ├── conftest.py # pytest fixture 定义 │ ├── test_login.py # 登录接口测试用例 │ ├── test_order.py # 下单流程测试用例 │ └── test_coupon.py # 优惠券接口测试用例 ├── reports/ # 测试报告输出目录 ├── data/ │ └── sql/ # 测试数据初始化脚本 ├── pytest.ini # pytest 配置文件 └── run.sh # 一键执行脚本这个结构的核心思路是配置归配置工具归工具用例归用例三者互不渗透。conf 目录只放环境相关的配置信息不写任何业务逻辑common 目录是基础设施层封装 HTTP 请求、数据库操作、断言方法等通用能力testcases 目录纯放测试用例用例只关心输入什么参数、断言什么结果不关心底层请求是怎么发出去的。这种分层最大的价值在于改环境信息的时候不需要动用例代码改请求封装的时候不会影响用例逻辑新增用例只需要在 testcases 目录下加文件就行。我在实际项目中见过很多把配置写在用例文件里、把请求逻辑写到用例里的反面教材结果就是改一个接口地址要全量搜索替换维护成本高到让人怀疑人生。3. 核心细节解析与实操要点3.1 HTTP 客户端封装与统一鉴权机制接口测试里最基础也最关键的一个环节就是 HTTP 请求的封装。直接用 requests 库虽然也能跑通但每个用例里都要重复写 headers、处理 token、处理超时代码冗余严重而且出问题的时候排查起来很分散。我在 common/client.py 里做了一个统一的客户端封装。import requests import time from conf.config import Config class APIClient: def __init__(self, base_url): self.base_url base_url self.session requests.Session() self.token None def set_token(self, token): self.token token self.session.headers.update({Authorization: fBearer {token}}) def request(self, method, path, **kwargs): url self.base_url path # 默认超时时间 10 秒防止某个接口卡住导致整个测试挂起 kwargs.setdefault(timeout, 10) # 统一记录请求日志 print(f[REQUEST] {method} {url} params{kwargs.get(params)} data{kwargs.get(json)}) start_time time.time() resp self.session.request(method, url, **kwargs) cost round(time.time() - start_time, 3) print(f[RESPONSE] status{resp.status_code} cost{cost}s body{resp.text[:200]}) return resp这个封装的几个细节都很有讲究。一是用 Session 对象而不是直接调 requests.getSession 会自动管理连接池多个请求复用同一个 TCP 连接性能上有明显提升。二是通过 set_token 方法统一注入鉴权头登录成功之后只需要调用一次后续所有用例都不用再关心 token 的问题。三是在请求和响应处打日志并且截断响应体前 200 个字符这样哪怕断言失败了看控制台日志就能快速定位是请求参数的问题、鉴权的问题还是服务端的问题。注意:打印日志的时候一定不要打印完整响应的 body某些接口会返回敏感的用户信息而且大响应体会把控制台刷得没法看。截断打印是行业里的常见实践。3.2 数据库直连断言与数据隔离方案光验证 HTTP 响应是不够的这是 Test001 项目里我最坚持的一个设计。响应体说扣减库存成功你就信了数据库里查一下才能确认真的扣了。所以我专门封装了 db.py 来处理数据库直连和断言。import pymysql from conf.config import Config class DBClient: def __init__(self): self.conn pymysql.connect( hostConfig.DB_HOST, portConfig.DB_PORT, userConfig.DB_USER, passwordConfig.DB_PASSWORD, databaseConfig.DB_NAME, charsetutf8mb4 ) def query_one(self, sql): with self.conn.cursor() as cursor: cursor.execute(sql) result cursor.fetchone() return result def query_all(self, sql): with self.conn.cursor() as cursor: cursor.execute(sql) result cursor.fetchall() return result def execute(self, sql): with self.conn.cursor() as cursor: affected cursor.execute(sql) self.conn.commit() return affected这个封装看起来简单但有一个特别重要的设计细节查询和更新操作全部通过同一个连接执行并且在每次 base 测试用例执行前通过 conftest.py 里的 fixture 做数据重置而不是在用例中间去改数据。这样能保证每个用例开始时数据库都处于一个已知的、可控的状态。数据隔离方面我的方案有三层每轮测试跑之前执行 data/sql 目录下的初始化脚本把涉及的表数据重置到基线状态。测试过程中创建的数据统一使用特殊前缀比如 test001_方便识别和清理。Redis 里所有测试产生的 key 设置过期时间默认 30 分钟避免脏数据堆积。这套方案实测下来最大的收益不是数据多干净而是用例执行结果的可复现性大幅提升。之前跑自动化测试最头疼的问题就是上一次跑通过了这一次失败了也没改代码啊一查往往是数据被上一次的用例污染了。有了这套隔离机制这类问题发生的概率大幅下降。3.3 多层断言策略与断言工具封装前面提到我的断言分成三层这里详细说一下每一层的设计思路和实现方式。第一层是状态码断言最简单但也是基础。接口返回 200 只能说明请求链路通了所以这一层的定位是必要条件而不是充分条件。第二层是业务字段断言这一层是重点。比如登录接口要断言返回的 data.token 是否存在且非空下单接口要断言返回的订单金额和前端传入的金额一致优惠券接口要断言返回的优惠券状态枚举值是否正确。这些断言要用到精确匹配、模糊匹配、大小比较等不同方式所以我封装了 assert_utils.pyimport re class AssertUtils: staticmethod def assert_jsonpath(data, path, expectedNone): 从嵌套 JSON 中提取指定路径的值同时支持反斜杠分隔和点分隔 if isinstance(path, str): path path.split(.) current data for key in path: if isinstance(current, dict): current current.get(key) elif isinstance(current, list): current current[int(key)] else: raise AssertionError(fcannot traverse path {path}, value type is {type(current)}) if expected is not None: assert current expected, fassert failed: path{path} actual{current} expected{expected} return current staticmethod def assert_regex(pattern, actual): assert re.search(pattern, actual), fregex match failed: pattern{pattern} actual{actual} staticmethod def assert_table_value(db_client, sql, expected): result db_client.query_one(sql) assert result is not None, fquery result is None, sql{sql} assert result[0] expected, ftable assert failed: actual{result[0]} expected{expected}第三层是数据落库断言这层直接查数据库确认业务状态变更。比如用户下单后订单表里要出现一条记录且订单状态字段是待支付支付回调后状态要变成已支付同时支付流水表要新增一条流水。这一层的断言是防止假成功的最后一道防线。三层断言都通过才判定这个用例是真正通过了。这个标准确实会提高用例开发的成本但换来的结果是只要自动化测试是通过的这个功能大概率就是真的没问题。这比一百个只断言状态码的用例都管用。4. 实战过程与核心环节实现4.1 登录接口用例的完整实现与参数化设计登录是所有业务链路的第一道关口也是我写的第一组用例。做完这组用例相当于把整个框架的关键能力都跑通了。登录接口的用例设计涵盖正常登录、错误密码、缺失参数、用户不存在、账号锁定五个场景其中前两个通过参数化实现后三个分别用独立用例处理。import allure import pytest from common.client import APIClient from common.assert_utils import AssertUtils from conf.config import Config client APIClient(Config.BASE_URL) class TestLogin: pytest.mark.parametrize(payload, expected_code, expected_token, [ ({username: test001_user, password: test001_pass}, 200, True), ({username: test001_user, password: wrong_pass}, 401, False), ]) def test_login_normal_and_wrong_password(self, payload, expected_code, expected_token): resp client.request(POST, /api/login, jsonpayload) assert resp.status_code expected_code if expected_token: token AssertUtils.assert_jsonpath(resp.json(), data.token) assert token client.set_token(token) else: AssertUtils.assert_jsonpath(resp.json(), code, 40100) def test_login_missing_username(self): resp client.request(POST, /api/login, json{password: test001_pass}) assert resp.status_code 400 AssertUtils.assert_jsonpath(resp.json(), code, 40001) def test_login_user_not_found(self): resp client.request(POST, /api/login, json{username: no_such_user, password: any_pass}) assert resp.status_code 404 AssertUtils.assert_jsonpath(resp.json(), code, 40401) def test_login_account_locked(self): # 先连续登录失败 5 次触发锁定策略 for _ in range(5): client.request(POST, /api/login, json{username: test001_user, password: wrong_pass}) resp client.request(POST, /api/login, json{username: test001_user, password: test001_pass}) assert resp.status_code 403 AssertUtils.assert_jsonpath(resp.json(), code, 40301)这里有个非常重要的细节错误的登录密码为什么用 401 而不是 200实际上很多项目组会把业务错误统一放在 HTTP 200 里返回用一个 code 字段来区分业务状态。对这个框架来说我的建议是先摸清被测系统的设计规范再决定断言策略。如果团队约定错误也返回 200那你就必须把断言重点放到业务 code 字段上。我在 Test001 里特意模拟了一个符合 RESTful 风格的系统就是为了演示这种基于状态码和业务码的双层断言模式。参数化设计有个特别大的好处同一个用例函数喂不同的参数组合就能覆盖多组场景而且 pytest 会为每一组参数生成独立的测试项报告里能清楚看到哪一组过了、哪一组挂了不会混在一起。我在实际项目中用这个方法把原来二十多个重复的用例函数压缩成了五六个参数化用例维护成本降了一半还多。4.2 下单全链路用例与数据初始化下单流程的用例是 Test001 里最复杂的部分因为涉及多接口串联、数据前置准备和后续清理。我设计的主流程用例是用户登录取得 token、创建商品、设置商品库存、清空购物车、添加商品到购物车、提交订单、校验订单金额和库存变化。整个流程的关键在于数据初始化和步骤衔接。我在 conftest.py 里定义了一个 fixture 来实现这些准备逻辑利用 pytest 的 fixture 作用域机制让每个测试用例都从一个干净且确定的状态开始import pytest from common.db import DBClient from common.client import APIClient from conf.config import Config pytest.fixture(scopefunction) def fresh_order_env(): 每个下单用例独立准备数据互不影响 db DBClient() # 重置商品与订单相关表 db.execute(DELETE FROM order_item WHERE order_id IN (SELECT id FROM orders WHERE user_idtest001_user)) db.execute(DELETE FROM orders WHERE user_idtest001_user) db.execute(DELETE FROM product WHERE product_name LIKE test001_%) # 插入一个测试商品初始库存 100 件 db.execute(INSERT INTO product (product_name, stock, price) VALUES (test001_prod, 100, 19.90)) client APIClient(Config.BASE_URL) resp client.request(POST, /api/login, json{username: test001_user, password: test001_pass}) token resp.json()[data][token] client.set_token(token) yield client, db # 用后清理 db.execute(DELETE FROM product WHERE product_name test001_prod)这个 fixture 用 function 作用域意味着每个用例执行前都会重新准备数据。等用例跑完用 yield 后面的代码做清理。有些团队为了节省时间会用 session 作用域只做一次准备但那样用例之间容易互相干扰。Test001 的目标是稳定优先所以每个用例独立准备、独立清理代价是执行时间会略长但换来的是结果可信度。主流程用例的核心段长这样def test_full_order_flow(fresh_order_env): client, db fresh_order_env # 添加商品到购物车 cart_resp client.request(POST, /api/cart/add, json{product_id: 1, quantity: 2}) assert cart_resp.status_code 200 # 提交订单 order_resp client.request(POST, /api/order/create, json{product_id: 1, quantity: 2}) assert order_resp.status_code 200 order_data order_resp.json()[data] order_id AssertUtils.assert_jsonpath(order_data, order_id) # 金额校验19.90 * 2 39.80 AssertUtils.assert_jsonpath(order_data, total_amount, 39.80) # 数据库校验订单状态是待支付扣减库存后剩余 98 AssertUtils.assert_table_value(db, fSELECT status FROM orders WHERE id{order_id}, PENDING) AssertUtils.assert_table_value(db, fSELECT stock FROM product WHERE id1, 98)真正执行的时候这一步踩过不少坑。比如产品单价是浮点数存到 MySQL 里如果字段类型是 DECIMAL(10,2)Python 查出来可能是一个 Decimal 对象直接和浮点数比较会出精度问题。这类细节问题我在后面的常见问题排查部分会专门展开。4.3 全量回归执行与报告输出框架搭好了用例写完了最终要落到一键执行、生成报告、快速定位问题这个目标上。我写了一个 run.sh 来封装执行逻辑#!/bin/bash source venv/bin/activate cd $(dirname $0) # 1. 初始化测试数据 echo reset test data mysql -h 127.0.0.1 -P 3307 -uroot -ptest001_pass test001_db data/sql/init.sql # 2. 执行测试并生成报告 echo run tests pytest testcases/ -v --tbshort --alluredirreports/allure-results # 3. 生成 HTML 报告 allure generate reports/allure-results -o reports/html --clean执行完之后到 reports/html 目录下打开 index.html就能看到一个结构化的测试报告里面每个用例是过的还是挂的、耗时多少、失败的具体断言信息、请求和响应日志都一目了然。这时候我还要提醒一个容易被忽略的点一键执行脚本一定要写成可重复执行的也就是说不管上一次跑完留下什么脏数据这次跑之前都要先清一遍再初始化。我在 init.sql 里会把所有涉及的表结构重建或者清空保证每次执行的环境都是全新状态。还有一个实用的技巧pytest 的 -k 参数支持按关键字筛选用例。日常开发的时候我不想跑全量回归只想跑下单相关的用例就用pytest testcases/test_order.py -v -k login or order这样能快速定位某个功能模块的问题等代码稳定了再跑全量回归。全量回归我一般是挂在 CI 平台上的每次代码合并前自动触发跑完把结果通知到群里。5. 常见问题与排查技巧实录5.1 环境与依赖相关的典型问题Test001 整个搭建和调试的过程中遇到了不少问题。我把其中最典型的、最值得记录的几个整理出来这些问题大概率你也会碰到。问题一时间戳冲突导致登录接口偶发失败。系统里有个安全机制登录请求带的时间戳参数超过 60 秒就拒绝。最初我写例子直接用了time.time()但加上调试断点或者网络慢的时候跨度超过阈值就会失败而且这种失败是间歇性的特别难排查。后来发现的解决方案是不要用真实时间戳而是从上次成功登录的时间附近取一个固定值或者把时间戳统一加到客户端封装里减少手工传参的出错概率。问题二Decimal 类型比较导致断言失败。这是数据库字段类型引发的经典问题。MySQL 的 DECIMAL 字段通过 pymysql 查出来是 Decimal 对象直接和 Python 的 float 比较时19.90 Decimal(19.90)结果是 False因为 Decimal 的精度比 float 高两者不相等。在断言金额这类数据时需要先把两边统一转成 Decimal 或者 int按分为单位再进行比较。我的 assert_table_value 里专门做了类型宽容处理用 Decimal 转换后再比避免因为精度问题误报。问题三端口占用导致测试环境起不来。本地原本已经装了 MySQLdocker-compose 起来之后发现 3306 端口冲突。这种问题解决方法很简单docker-compose 里把宿主机端口映射改掉就行。但更关键的是一定要在文档里写明每个容器对外映射的端口不然后来接手的人会一脸懵。5.2 用例编写与数据相关的常见问题问题四用例之间的数据相互污染。这是自动化测试里最棘手的问题之一。比如一个用例创建了一个用户 test001_user另一个用例条件查询的时候把这条数据也查出来了导致结果不符合预期。我的解决方案是严格执行 fixture 级别的数据清理并且所有用例专门生成带 test001_ 前缀的测试数据。这个方案不完美因为如果测试中断了清理代码可能不会执行数据库里会残留脏数据。所以 run.sh 里每次执行前会重新初始化一遍数据双保险。问题五异步逻辑导致断言时机不对。有些接口不是同步处理的比如下单之后订单状态可能由消息队列异步更新。刚开始写用例的时候调用下单接口后立刻查数据库发现订单状态还没变用例一直报失败但手动查明明是对的。这个问题调试了很久才发现是异步导致的。解决方案有两种一种是在用例里加轮询等待最多等 10 秒每 1 秒查一次另一种是尝试调用下游处理接口但这种方式对测试环境的改造比较大我最终选择了轮询方案。def wait_for_order_status(db, order_id, expected_status, timeout10): import time deadline time.time() timeout while time.time() deadline: status db.query_one(fSELECT status FROM orders WHERE id{order_id})[0] if status expected_status: return True time.sleep(1) return False5.3 框架设计与维护中的坑问题六滥用 fixture 嵌套导致定位问题困难。前期为了图省事我把很多数据准备逻辑都塞到 conftest.py 的 fixture 里而且还让 fixture 层层依赖。结果用例报错的时候要先看三层 fixture 才知道是哪一步出的错。后来我调整了策略fixture 只做环境准备和基础数据初始化复杂的业务数据准备单独放在测试用例里用辅助函数处理。这样一来定位问题的路径短了很多。问题七过分依赖固定等待时间。早期脚本里遍布time.sleep(3)想着等 3 秒总能等到异步处理完成吧。但这种方式非常脆弱系统压力大的时候 3 秒不够空闲的时候 1 秒就够了固定等待要么浪费时间要么不够用。后来全部改成上面的轮询等待方式按实际状态判断是否继续而不是猜时间。这种做法不但更稳整体执行时间反而缩短了。排查建议:凡是遇到偶发失败或者本地能过、CI 挂掉的情况先查日志看请求和响应的时间线重点对比倒数第二步和最后一步的数据状态。多数问题不是代码逻辑错而是环境数据不一致或者时序不一致导致的。6. 复盘总结与后续扩展方向6.1 这个项目给我带来的最大收益Test001 这套框架从搭建到跑通全部用例前后花了两个周末。投入的时间不算多但换来的收益是长期性的现在每次业务代码要改动我只需要跑一遍全量回归二十多分钟就能确认核心链路没有被破坏。这比我之前一遍一遍手工点流程快太多了而且几乎没有漏测的可能。当然也有几个地方从一开始就应该做得更好。比如数据初始化脚本应该纳入版本管理而不是只在本地有CI 平台上的集成应该早点接上而不是手动跑完再去部署。不过这些都属于做了才知道该怎么做的经验教训Test001 作为第一版实验项目它的定位就是把核心链路跑通、验证思路可行这些不足留给后续版本迭代优化完全合情合理。6.2 可以继续扩展的方向这个框架目前覆盖的是接口层的自动化验证往后面走我觉得有三个方向值得探索。第一个方向是把性能和稳定性测试加进来。接口自动化只关注功能正确性但系统上线前还得知道它在并发压力下的表现。可以在 Test001 基础上集成一个轻量级的压测模块用 locust 或者直接多线程并发请求测出接口的 QPS 和响应时延作为发布上线的参考数据。第二个方向是把数据构造能力做得更通用。目前的测试数据主要靠 SQL 初始化脚本和 API 调用灵活性有限。可以引入一套数据工厂机制通过配置文件声明每个业务实体需要哪些字段、字段取值规则、数据依赖关系然后自动生成完整的测试数据集。这样新增一个项目的测试就不再需要手写一堆初始化 SQL 了。第三个方向是沉淀一套标准的断言规则库。我在 Test001 里用的断言还是人工写死的如果能把一些通用的断言规则比如响应结构必须包含哪些字段、金额精度必须统一、时间字段必须符合 ISO 格式沉淀成配置让框架自动检查那写用例的效率会进一步提升。以我个人的经验来看像 Test001 这种按需搭建、快速验证、逐步完善的测试框架比一开始就追求大而全的测试平台要务实得多。先把最核心的链路保护起来把能跑、能查、能稳定复现这个基本盘做扎实再根据实际业务需求慢慢扩展。自动化测试这件事最怕的不是起步晚而是起步就拉开一个庞大的阵仗最后维护不下去成了摆设。小步快跑持续迭代反而更容易走远。
返回列表