Python登录接口实战:从密码加密到Session/Token登录态保持
做登录接口算是Python后端入门里最典型、也最容易被低估的一个练习。项目名里带着“携程登陆”说明不少人是想拿真实网站当靶子练手这个思路没错但我的建议是先别急着去模拟别人家的登录先自己用Python写一个完整的登录接口把账号校验、密码存储、登录态保持这一套闭环跑通然后再回头去分析第三方网站的登录设计你会发现一切都顺了。这篇文章就按这个路子来先讲登录接口到底在做什么、怎么做再讲怎么用requests去验证接口最后把我在实际练习里踩过的坑整理一遍。适合刚学完Python基础语法、想动手做第一个后端项目或者想看明白“网站登录到底是怎么回事”的朋友。1. 项目整体设计与思路拆解1.1 登录接口的本质验证身份加维持状态HTTP协议有个特点它是无状态的。什么意思就是一个HTTP请求从客户端发到服务器服务器处理完返回响应这件事就结束了。下一次你再发一个请求服务器完全不记得你之前是谁。可真实业务不一样你登录了携程要看订单、要享受会员价服务器必须知道“这次请求的用户就是刚才登录的那个人”。所以登录接口本质上就干两件事第一验证你提交的账号密码是不是合法第二在验证通过之后发给你一个“凭证”让你后续的每个请求都能证明自己的身份。你可以把登录理解成进小区门禁你报上房号和姓名保安核对业主名单确认无误后给你一张门禁卡之后你每次刷卡进单元门门禁系统一查卡就知道你是谁。这个项目叫“用户登录接口”但拆开来看核心模块有四个客户端提交、服务端校验、凭证签发、凭证校验。很多人学的时候只盯着“校验密码”那一段代码忽略了“凭证”这个环节结果后续接口怎么都做不出“登录后才能访问”的效果。这个认知必须先纠正过来。1.2 两条学习路线自建接口和模拟第三方登录针对“携程登陆”这个场景有两条完全不同的学习路线。路线A是自建接口。用Flask或FastAPI写一个登录API路由自己定义、用户数据自己存、密码自己加密、登录凭证自己签发。这个路线的学习目标是掌握后端API设计、安全存储、鉴权机制。路线B是模拟登录第三方网站。用requests抓包、分析携程登录接口的参数构成模拟浏览器提交请求。这个路线的学习目标是理解HTTP协议、会话保持、参数加密、验证码处理。我见过很多人一上来就选路线B结果卡在验证码和参数加密上连登录按钮的请求都没抓到很快就放弃了。这两条路线其实不冲突但顺序很关键。对比项路线A 自建接口路线B 模拟第三方登录学习重点API设计、鉴权、安全HTTP协议、参数构造、会话管理难度中等逻辑完全可控较高受目标网站策略影响环境依赖本地Python即可需要抓包工具、处理验证码合规性完全自主可控需要遵守目标网站服务条款我的建议是走A为主跑通之后再用B做验证和对照。这样你既知道“标准答案”是什么又能看到真实世界的接口“长什么样子”两者一对比收获最大。1.3 登录流程的功能拆解一个完整的登录流程最少包含四个环节客户端提交用户输入用户名和密码前端通过表单或JSON把数据POST到后端接口。服务端校验检查参数是否完整、用户是否存在、密码是否正确。这里有一个安全细节无论用户不存在还是密码错误统一返回“用户名或密码错误”防止别人通过提示内容探测已注册账号。凭证签发校验通过后生成Session ID或者Token返回给客户端。凭证校验客户端后续请求携带凭证服务端验证有效后放行否则返回401。很多网上的教程只写到了第2步导致初学者以为登录接口就是把密码比对一下。实际上第3、4步才是登录接口和普通“密码验证函数”的区别。在做项目设计时一定要把整个闭环画出来。2. 环境准备与核心概念扫盲2.1 Python环境与依赖安装做这个项目不需要特别新的Python版本3.9以上就行。如果你还没装Python去官网下载安装包安装时记得勾选“Add Python to PATH”。装好后打开终端确认一下python --version强烈建议用虚拟环境不要让项目依赖污染全局环境。虚拟环境就像给每个项目单独开了一个小房间A项目装Flask 2.0B项目装Flask 3.0互不干扰。mkdir login-demo cd login-demo python -m venv venv # Windows下激活 venv\Scripts\activate # Mac/Linux下激活 source venv/bin/activate激活后命令行前面会出现(venv)接下来安装依赖pip install flask flask-cors bcrypt requests这几个包的作用Flask提供Web服务框架我们用路由和请求处理功能flask-cors解决本地调试时的跨域问题bcrypt用来做密码哈希后面详细讲requests用来模拟客户端请求验证我们写的接口。2.2 几个必须搞懂的核心概念写登录接口之前有几个概念绕不开我用最直白的话讲一遍。HTTP无状态服务器默认不记得你是上次那个请求。服务端和客户端之间只有“请求-响应”这种一次性交互。Cookie为了解决无状态问题服务器可以在响应头里塞一个Set-Cookie字段浏览器收到后会自动保存并在之后每次请求的请求头里自动带上。Cookie本质上就是一小段存储在客户端的数据。Session服务器端记录用户状态的地方。用户登录成功后服务器生成一个Session ID把这个ID通过Cookie发给客户端同时自己保存一份Session数据比如用户ID、登录时间。下次请求来了服务器根据Cookie里的Session ID找到对应数据。Session可以存在内存、文件或者Redis里项目重启内存型Session会全部丢掉。Token另一种凭证方案。服务器不存Session数据而是把用户信息、过期时间等签名后生成一个字符串发给客户端客户端每次请求把Token放在请求头里服务器验签后就能确认用户身份。JWT就是最常见的一种Token格式。密码哈希把密码通过单向算法变成一串固定长度的字符且无法从字符反推原始密码。bcrypt的特殊之处在于它会自动加盐一段随机数据所以同一个密码每次哈希出来的结果都不一样这能有效防止彩虹表破解。Session和Token的选择做项目时经常纠结维度SessionToken存储位置服务端保存客户端保存服务端压力有需要存储无验签即可分布式扩展需要共享存储天然支持失效控制服务端可随时删除要到过期时间才失效学习难度简单中等3. 核心实现从零写一个用户登录接口3.1 先定义用户数据从字典到SQLite做学习项目时用户数据最简单的存法就是Python字典key是用户名value是密码哈希。项目一重启数据就没了但用来理解流程完全够用。# users_db.py import bcrypt # 模拟用户表实际项目要用数据库 users_db {} def create_user(username, password): if username in users_db: return False, 用户名已存在 password_hash bcrypt.hashpw(password.encode(utf-8), bcrypt.gensalt()) users_db[username] password_hash return True, 注册成功 def verify_user(username, password): password_hash users_db.get(username) if not password_hash: return False return bcrypt.checkpw(password.encode(utf-8), password_hash)如果想模拟得更真实一点可以用SQLite它不需要额外安装服务Python自带sqlite3模块文件型数据库适合学习和轻量项目。import sqlite3 def init_db(): conn sqlite3.connect(login_demo.db) conn.execute( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() conn.close()为什么推荐用数据库而不是字典因为真实项目的用户表不会只有用户名和密码两个字段还有手机号、邮箱、状态、注册时间、最后登录时间这些用字典管理会越来越乱。学习阶段养成建表的习惯后面接MySQL、PostgreSQL都顺理成章。3.2 登录接口的校验逻辑一步步写出来用Flask写登录接口代码逻辑并不复杂但每一步都不能少。# app.py from flask import Flask, jsonify, request import bcrypt from users_db import verify_user app Flask(__name__) app.route(/api/login, methods[POST]) def login(): data request.get_json(silentTrue) or {} username data.get(username, ).strip() password data.get(password, ) # 第一道检查参数非空 if not username or not password: return jsonify({code: 400, message: 用户名和密码不能为空}), 400 # 第二道检查用户名密码校验 if not verify_user(username, password): return jsonify({code: 401, message: 用户名或密码错误}), 401 # 第三道签发凭证后面会展开 token 临时凭证 return jsonify({code: 200, message: 登录成功, token: token}), 200 if __name__ __main__: app.run(debugTrue)这里有两个细节值得注意。第一个是request.get_json(silentTrue)如果客户端传的不是JSON格式get_json()会抛异常silentTrue表示解析失败时返回None避免接口直接500。第二个是密码字段的.strip()很多学生在测试时输入密码多打了一个空格死活登录不上其实不是接口逻辑错了是数据没清理。错误码这里我用了400和401含义分别是“客户端请求有误”和“身份认证失败”这是Web开发里约定俗成的语义。新手容易犯的错误是无论什么异常都返回200然后在code字段里写个失败。这样做不是不行但会让前端处理逻辑非常麻烦不利于排查问题。3.3 密码加密别再用MD5存密码了很多入门教程会教用hashlib.md5()给密码加密这个做法在现在来看非常危险。MD5和SHA1这类摘要算法计算速度极快黑客可以用彩虹表预计算好的“密码-哈希值”对照表在几秒钟内反查出弱密码。bcrypt之所以被推荐核心原因是它慢。慢是故意的因为攻击者要暴力破解也需要同样的计算时间。bcrypt还有一个特性是自动加盐每次哈希时会随机生成一段盐数据混入密码所以即使两个用户的密码完全相同存储在数据库里的哈希值也是不一样的。# 注册时生成哈希 password_hash bcrypt.hashpw(password.encode(utf-8), bcrypt.gensalt()) # 登录时校验 is_valid bcrypt.checkpw(password.encode(utf-8), stored_hash)注意校验的时候不能用stored_hash new_hash这种方式直接比较字符串因为盐是随机的对同一个密码做两次哈希结果不同。checkpw的处理方式是先从stored_hash中取出盐再用相同盐去哈希待验证密码最后比对结果。密码加密这件事还有几个实践中的经验数据库字段长度至少设为60个字符bcrypt哈希输出是60个字符设短了存不进去。不要自己造加密算法即使是资深工程师也不建议自己写密码学逻辑用成熟库就好。生产环境建议再加一层服务器端密钥pepper把密钥配置在环境变量里不写进代码库。3.4 登录态保持Session和Token一次搞定登录接口只校验密码是远远不够的你还需要让后续请求知道“我是谁”。这一步是把接口做成真正可用的关键。Session方案实现最简单。Flask自带Session但需要设置secret_key它用来给Session Cookie签名防止Cookie被篡改。import secrets app.secret_key secrets.token_hex(16) app.route(/api/login, methods[POST]) def login(): # ... 前面的校验逻辑 session[username] username session[login_time] datetime.now().isoformat() return jsonify({code: 200, message: 登录成功}), 200 app.route(/api/me, methods[GET]) def me(): username session.get(username) if not username: return jsonify({code: 401, message: 未登录}), 401 return jsonify({code: 200, username: username}), 200上面这版代码登录成功后session[username]会被存起来浏览器会自动保存Cookie后续请求自动带上服务端通过session.get(username)判断是否登录。Token方案更现代一些特别适合前后端分离项目。登录成功后生成一个随机Token服务端用一个字典保存Token到用户名的映射。客户端后续请求在请求头里加Authorization: Bearer token。import uuid tokens_db {} app.route(/api/login, methods[POST]) def login(): # ... 前面的校验逻辑 token uuid.uuid4().hex tokens_db[token] username return jsonify({code: 200, message: 登录成功, token: token}), 200 app.route(/api/me, methods[GET]) def me(): auth_header request.headers.get(Authorization, ) token auth_header.replace(Bearer , ) if auth_header else username tokens_db.get(token) if not username: return jsonify({code: 401, message: 未登录或凭证已失效}), 401 return jsonify({code: 200, username: username}), 200这个tokens_db同样是临时的服务重启就失效。生产环境要么用Redis存要么直接用JWT无状态Token服务端不用存但JWT要处理密钥轮换和过期时间学习阶段用内存字典完全够用。4. 实操过程用 requests 验证登录接口4.1 为什么要用requests模拟登录很多初学者会困惑接口都写好了用浏览器访问不就行了为什么要用requests再模拟一遍登录原因有二。第一浏览器帮你自动处理了Cookie、Session、重定向这些底层细节你看不到真正的HTTP交互过程而requests需要你手动管理Session这逼着你把登录流程理解透。第二你自己写的接口功能单一但将来你要对接第三方API、写自动化测试脚本requests是必备技能。用requests做接口验证就像你自己亲自走了一遍从客户端到服务端的完整链路哪个环节有问题一目了然。4.2 写一个测试脚本验证自建接口假设你已经启动了Flask服务app.run(debugTrue)默认跑在http://127.0.0.1:5000。写一个测试脚本import requests BASE_URL http://127.0.0.1:5000 # 创建一个Session对象它会自动保存Cookie session requests.Session() # 1. 注册一个用户 register_data {username: test_user, password: 123456} resp session.post(f{BASE_URL}/api/register, jsonregister_data) print(注册状态码:, resp.status_code, resp.json()) # 2. 用错误密码登录预期返回401 wrong_data {username: test_user, password: wrong} resp session.post(f{BASE_URL}/api/login, jsonwrong_data) print(错误密码登录:, resp.status_code, resp.json()) # 3. 用正确密码登录 right_data {username: test_user, password: 123456} resp session.post(f{BASE_URL}/api/login, jsonright_data) print(正确密码登录:, resp.status_code, resp.json()) # 4. 访问需要登录的接口 resp session.get(f{BASE_URL}/api/me) print(获取用户信息:, resp.status_code, resp.json())这里一定要用requests.Session()而不是裸的requests.post()。Session对象像一个浏览器标签页它会自动保存服务端返回的Cookie后续请求自动带上。如果用裸方法每次请求都是“新开的浏览器”登录状态根本保持不住。如果你在/api/login里用的是Token方案那测试脚本又不一样需要手动从响应里取Token然后塞进后续请求的请求头里resp session.post(f{BASE_URL}/api/login, jsonright_data) token resp.json().get(token) headers {Authorization: fBearer {token}} resp session.get(f{BASE_URL}/api/me, headersheaders)这一步能帮你彻底搞懂Session和Token的区别Session靠Cookie自动携带Token需要手动放进请求头。4.3 看第三方网站的登录流程以携程类网站为例把自己接口调通之后再回头看你项目名里的“携程登陆”就会更有感觉。携程这类旅游网站的登录接口大体流程是客户端打开登录页页面加载时会请求一些初始化接口可能返回一个加密公钥或者会话ID。用户输入手机号/用户名和密码点击登录按钮。前端脚本把密码做加密处理和用户名一起以表单格式POST到/api/login之类的地址。服务端校验通过返回登录成功的响应同时在响应头里写入Cookie比如用户的专属会话标识。后续每次请求浏览器自动在请求头里带上这些Cookie服务器就知道这个请求属于哪个用户。很多网站为了保护登录接口还会加几道关卡图形验证码或滑块验证码把敏感字段加密传输校验请求的User-Agent和Referer限制同一IP的频繁登录请求。我见过不少新手拿大平台练习卡在验证码这一步然后到处找“自动过验证码”的方案这其实是跑偏了。学习阶段应该明白这些保护措施的存在本身就是安全设计的一部分研究它们不是不能做但要先分清学习目的。合规的做法是先用登录流程简单、无验证码的演示站或自建接口练习理解原理后再去分析外部接口的设计思路。4.4 一个通用模拟登录代码模板模拟第三方登录时requests的代码套路其实是固定的你只需要把URL和参数替换成目标网站的import requests # 1. 创建Session保持所有请求的Cookie session requests.Session() # 2. 伪造常见的请求头很多网站会检查这个 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/login } session.headers.update(headers) # 3. 先GET访问登录页获取初始Cookie和可能的隐藏表单字段 login_page session.get(https://example.com/login) print(登录页状态码:, login_page.status_code) # 4. 构建登录参数以实际抓包结果为准 login_data { username: your_username, password: your_password } # 5. POST提交登录 login_resp session.post(https://example.com/api/login, datalogin_data) print(登录响应状态码:, login_resp.status_code) print(登录响应内容:, login_resp.text) # 6. 访问需要登录后才能访问的页面 profile_resp session.get(https://example.com/account) print(个人中心状态码:, profile_resp.status_code)这个模板的核心就是session对象的复用。只要登录成功后响应头里有Set-Cookierequests.Session会自动记住并在后续请求中携带。注意代码里https://example.com是我写的占位符实际使用中你必须先抓包确认登录接口的真实地址、请求方式和参数名。不同网站差异特别大有的用JSON传参有的用表单传参有的还要先请求一个token作为额外参数。5. 常见问题与排查技巧实录5.1 密码明明对的登录却一直提示错误这个问题出现的频率最高排查思路按顺序来第一确认你存的是哈希值而不是明文并且校验用的是bcrypt.checkpw()而不是字符串比较。如果你把两次加密结果直接对比因为盐是随机的永远不可能相等。第二查数据库字段长度。bcrypt哈希长度是60字符如果表结构里password_hash字段只设置了50字符存进去的就会被截断后面再怎么校验都不对。第三确认前端传过来的密码没有额外的空格。我在调试时打印过请求数据发现密码变成了123456 就是输入法或者手滑多打的空格。接口里写password data.get(password, ).strip()能规避一部分问题。排查这种问题时最快的办法是在接口里加一行打印把收到的用户名和密码长度打出来。别觉得低级实际排查效率最高。5.2 登录成功了下一个请求又提示未登录这个问题的根源几乎都在“凭证没有保持住”。如果你用Session方案要先确认客户端用的是同一个Session对象而不是拿裸requests每次重新POST。很多人测试时写了两个脚本登录脚本通过了又另起一个脚本去访问/api/me结果当然未登录因为两个脚本的Session互不相认。如果客户端一切正常那就是服务端的问题。Flask默认的Session数据存在客户端Cookie里并且有签名保护但只要secret_key变了之前签发的Cookie全部失效。调试模式下重启服务如果secret_key是通过随机数生成的那就等于每次重启全部登录态失效。解决方法是把secret_key固定写死在配置里或者从环境变量读取。另外要注意Cookie的有效期。Flask的session.permanent True加上PERMANENT_SESSION_LIFETIME配置才会持久化否则浏览器一关Cookie就没了。5.3 接口返回302而不是JSON自己的接口如果出现这种情况一般是路由逻辑里混用了重定向和JSON返回。比如你写了两个装饰器app.route(/login, methods[POST]) def login(): if not verify_user(...): return redirect(/login) # 错误返回重定向 return jsonify(...) # 成功返回JSON前端期望的是JSON结果收到302重定向就乱了。写API接口时统一返回状态码加JSON体不要混用页面跳转逻辑。模拟第三方网站时requests默认会跟随重定向所以你看到的是跳转后的页面内容而目标真正返回的302可能带着重要信息比如登录失败的标识。排查时可以设置allow_redirectsFalse看看每一步的真实响应。5.4 表单字段名对不上第三方接口的字段名大概率不会是username和password可能是loginName、userAccount、pwd、passwd、secret。你拿自己接口的习惯去猜别人的参数名大概率是错的。正确做法是用抓包工具浏览器开发者工具里的Network面板就行看清楚真实请求发的到底是什么。看请求体的格式、字段名、是否加密、Content-Type是什么照着抄。我踩过的一次坑对方登录请求的某个字段名看着像明文实际值是前端加密后的密文我原样塞了明文进去服务端一直报“参数有误”。所以看到请求体里有看不懂的长字符串先怀疑加密。5.5 验证码怎么处理先说结论学习阶段验证码最稳妥的处理方式是“绕开它”而不是“破解它”。图形验证码的识别从简单阈值分割到深度学习模型都有但做这个事首先需要大量样本和算力而且很多验证码已经做了扭曲、干扰线、点选、滑块识别成本很高。打码平台可以做到自动化识别但这类服务通常有使用条款和成本而且如果你把它用在未经授权的系统上存在法律风险。更实际的学习方式是在自己写的系统里实现验证码逻辑理解它的设计原理然后做功能测试时预留测试开关把验证码校验临时关掉。这样你既掌握了验证码在业务流程中的位置又不浪费时间在“过验证码”这种偏门技术上。5.6 一套实用的接口状态码规范接口调多了之后你会发现状态码的统一比想象中还重要。前后端对接时混乱的状态码规范是吵架的常见原因。状态码含义常见场景200请求成功登录成功、获取数据成功400客户端参数错误缺少用户名、JSON格式错误401未认证或凭证失效没登录、Token过期403已认证但无权限普通用户访问管理员接口404资源不存在路由写错、用户不存在500服务端内部错误代码异常、数据库连接失败我的习惯是HTTP状态码保证大方向正确响应体里再放一个业务code字段做细粒度区分。比如HTTP 200代表请求到达服务端并处理完成但业务上密码错误时code可能是10001。这个方案不是唯一解但团队协作时一定要统一。6. 项目延展把登录接口做成“能上线的样子”6.1 增加注册接口和约束校验登录前一定有注册否则用户哪里来。注册接口的要点是用户名唯一重复注册要返回明确提示。密码复杂度校验至少包含字母和数字长度不少于8位。确认密码字段在前端做一致性校验就行后端不一定要接收两次密码。app.route(/api/register, methods[POST]) def register(): data request.get_json(silentTrue) or {} username data.get(username, ).strip() password data.get(password, ) if not username or not password: return jsonify({code: 400, message: 用户名和密码不能为空}), 400 if len(username) 3 or len(username) 20: return jsonify({code: 400, message: 用户名长度需要在3到20个字符之间}), 400 if len(password) 8: return jsonify({code: 400, message: 密码长度不能少于8位}), 400 success, msg create_user(username, password) if not success: return jsonify({code: 400, message: msg}), 400 return jsonify({code: 200, message: 注册成功}), 200这里的原则是所有来自客户端的输入都不可信后端必须做完整校验。别指望前端帮你过滤掉非法数据前端校验只是用户体验优化后端校验才是安全底线。6.2 加上验证码逻辑在自己写的系统里实现一个最简验证码流程只需要三步。第一步登录页面获取验证码图片时服务端生成一个随机字符串把答案存到Session里并把验证码图片返回给前端。Python里的captcha库可以生成图片验证码也可以用Pillow自己画。第二步用户提交登录请求时表单里带上用户输入的验证码。服务端先校验验证码是否正确再校验用户名密码。第三步验证码一次性使用不管用户输对还是输错校验完马上从Session里删掉。同时设置5分钟过期时间防止验证码长期有效被滥用。# 伪代码演示验证码流程 from captcha.image import ImageCaptcha import random, string app.route(/api/captcha, methods[GET]) def captcha(): text .join(random.choices(string.digits, k4)) session[captcha] text session[captcha_time] datetime.now().timestamp() image ImageCaptcha().generate_image(text) # 返回图片具体实现略验证码的作用是区分“操作的是人还是程序”虽然现在的高级验证码已经不靠文字识别了但核心逻辑仍然是你提交答案、服务端比对、一次性失效。6.3 日志、限流与安全加固登录接口是暴力撞库攻击的首选目标哪怕只是学习项目也要有安全意识。限流是最基本的一层防护。用Flask实现一个简单的内存限流记录同一个IP最近1分钟内的失败次数超过5次就临时禁止登录10分钟。生产环境会用Redis做分布式限流思路是一样的。日志也不能少。登录成功、登录失败、凭证校验失败这些关键事件都要打日志记录时间、IP、用户名、失败原因。出了安全问题日志是第一手追溯证据。import time, logging failed_attempts {} def is_rate_limited(ip): now time.time() # 清理超过1分钟的记录 failed_attempts[ip] [t for t in failed_attempts.get(ip, []) if now - t 60] return len(failed_attempts.get(ip, [])) 5 # 在登录接口校验失败时记录 failed_attempts.setdefault(remote_ip, []).append(time.time())安全加固再往外扩展还包括HTTPS传输、Token过期时间、密码定期修改策略、敏感操作二次验证。这些不是一节课能学完的但你要知道登录接口不是一个孤立的“密码比对器”它是一整套安全体系的门面。我自己练过几个类似的项目之后最大的感触是登录接口这个练习的性价比极高。它表面上是“写一个接口”实际上把Web开发里HTTP协议、状态管理、密码安全、接口设计这些核心话题全串起来了。把Session和Token这两种方案都手动实现一遍把requests测试脚本写通你对“前后端是怎么交互的”这件事的理解会发生质变。后面再升级的话可以把存储换成MySQL把Session换成Redis或者把Token换成JWT逻辑都是通的。我当时做完这个项目后再去看任何网站的登录逻辑第一反应不再是“好复杂看不懂”而是下意识地开始拆解它的请求流程、参数构成和服务端校验策略。这种看问题的方式才是这个练习最有价值的部分。