🐍 第9章 使用 Flask 建置网站服务
📄 一句话概括

GET 是「我要看什么」,POST 是「我要你改什么」。 把删除操作写成 GET, 等于让任何一张图片链接都能删你的数据。 一次请求带过来的东西全在 request 对象里:路径、查询参数、表单、请求头、来源 IP。 args 是问号后面的,form 是请求体里的,两个别搞混。 把表单字段写进地址栏再读 args,会永远读到空。

📝 本章和前面几章的关系

前两篇的页面都是「只读」的。这一篇让页面能收东西。 教材第 9 章最后两节是【处理表单】和【认识 HTTP 请求消息】。 演示里的表单流程是真跑的:GET 拿到表单页、POST 提交成功、POST 错数据拿 400,三个都实测。 最后那个「为什么刷新会重复添加」是个真问题,标准解法叫 POST-重定向-GET, 第 12 章的留言板会用。

一、一个能用的表单页

<form method="post" action="/add"> 两个属性决定一切:method 是用哪种方式提交, action 是提交到哪个地址。 里面每个 <input> 的 name 就是服务端收到时的字段名。 <input name="qty"> 提交之后,服务端能拿到 qty 这个字段。 name 忘了写,那个字段就压根不会提交。 这个坑很常见——输入框显示正常,用户也填了, 服务端就是收不到。

<button type="submit"> 是提交按钮。 type 不写的话,在某些浏览器里会变成普通按钮,点了没反应。 表单里不写 method,默认是 GET。 这时候你在输入框里填的东西会出现在网址上,被人截图、被人收藏、留在浏览器历史里。

二、GET 和 POST 差在哪

GET POST
参数在哪 网址问号后面 请求体里
能被收藏/分享吗 能 不能
会留在浏览器历史里吗 会 不会
有长度限制吗 有,看浏览器和服务器 基本没有
该用来干什么 查询、翻页、搜索 提交、修改、上传

一句话记法:GET 是「我要看什么」,POST 是「我要你改什么」。 这条规矩不是风格问题,是安全问题。把删除操作写成 GET,等于让任何一张图片链接都能删你的数据。 怎么做到? 别人在论坛里发一个 <img src="https://你的站/delete?id=7">,你登录着那个站、又打开了这个帖子, 浏览器就替你把那个 GET 发了。你什么都没点,数据没了。

这件事的正式名字叫 CSRF(跨站请求伪造)。防它的第一道就是:改数据的操作不许用 GET。

三、在服务端把东西收下来

request.method 区分两种请求。 演示里 GET /add 返回「这个地址只接受表单提交」,POST /add 返回「已收下 六角螺栓 M8 × 120, 当前共 1 条」。 表单字段从 request.form 取。注意它是字典,取不到给 None,不是抛异常。 数量那个字段要转成整数,用户可能填「很多」。 演示里 POST 一个 qty=很多,服务端返回 400,正文是「数量必须是数字,收到的是 '很多'」。

400 是「你发来的东西不对」。 跟 404(地址不存在)、500(我坏了)都不一样, 它是「请求本身有问题」,责任在发请求的那一方。 关键的一句:校验一定要在服务端做。 前端那点检查只能挡住手滑, 挡不住直接发请求的人——上面 qty=很多 这一条就是绕过页面直接 POST 进来的。 同一段代码可以既显示表单又处理提交(判断 request.method),但更常见的写法是拆成两个函数, 各自只管一件事。

四、一次请求里能拿到什么

演示第四段请求了 /info?page=2&kw=螺栓,然后打出来七项。

取的写法 是什么
request.method 'GET'
request.path '/info'(不含问号后面)
request.args 查询参数,{'kw': '螺栓', 'page': '2'}
request.form 表单字段,GET 请求时是空的
request.headers 请求头,User-Agent、Host 都在里面
request.remote_addr 来源 IP

args 是问号后面的,form 是请求体里的,两个别搞混。 把表单字段写进地址栏再读 args, 会永远读到空。 做接口、做鉴权、做统计,都是从这个对象里取。 比如按 remote_addr 统计访问来源,按 User-Agent 判断是手机还是电脑。 request.args 和 request.form 都是「多值字典」——同一个名字可以出现好几次, 要用全部得 getlist('name')。普通 ['name'] 只给第一个。

五、为什么刷新会重复添加

表单提交成功,页面显示「已添加」。你按 F5 刷新,又加了一条。 原因是:浏览器的刷新是「重发上一次的请求」。上一次是 POST,它就再 POST 一遍。 服务器不知道这是「同一个提交」还是「用户又想加一条」。 标准解法叫 POST-重定向-GET:处理完表单,不直接返回页面,返回一个 302 让浏览器跳到结果页。

这样浏览器最后停在的是一个 GET 页面,刷新就是重发那个 GET——只是重新看一眼,不会重复提交。 代码上就是一句 return redirect(url_for('list_page')),不难。 难的是记住要写。 忘了写,用户每刷新一次数据就多一条。 更严格的系统还会给表单加一个一次性令牌(CSRF token),同一个令牌只能用一次。 第 12 章的留言板会用前一种做法(重定向),因为简单够用。

六、动手 Demo:表单和它背后的那次请求

起一个带表单的应用,先 GET 表单页,再 POST 提交,最后 POST 一个错数据。 然后把一次请求里能拿到的东西全打印出来。 最后看「刷新重复添加」是怎么被重定向解决的。

import logging, threading
from flask import Flask, request, redirect, url_for
from werkzeug.serving import make_server
import requests

logging.getLogger('werkzeug').setLevel(logging.ERROR)
app = Flask(__name__)
DB = []

@app.route('/form')
def form():
    return ('<form method="post" action="/add">'
            '品名 <input name="name" value="">'
            '数量 <input name="qty" value="">'
            '<button type="submit">提交</button></form>')

@app.route('/add', methods=['GET', 'POST'])
def add():
    if request.method == 'GET':
        return '这个地址只接受表单提交', 200
    name = (request.form.get('name') or '').strip()
    qty = (request.form.get('qty') or '').strip()
    if not qty.isdigit():
        return '数量必须是数字,收到的是 %r' % qty, 400
    DB.append((name, int(qty)))
    return '已收下 %s × %d,当前共 %d 条' % (name, int(qty), len(DB)), 200

@app.route('/info')
def info():
    return ('UA前40    %r\n主机       %r\n方法       %r\n来源IP     %r\n'
            '查询参数     %r\n表单       %r\n路径       %r'
            % (request.headers.get('User-Agent', '')[:40], request.host,
               request.method, request.remote_addr,
               dict(request.args), dict(request.form), request.path))

@app.route('/add2', methods=['POST'])
def add2():
    DB.append(((request.form.get('name') or '').strip(), 1))
    return redirect(url_for('form'))

srv = make_server('127.0.0.1', 0, app)
B = 'http://127.0.0.1:%d' % srv.server_port
threading.Thread(target=srv.serve_forever, daemon=True).start()

print('① 一个能用的表单页')
r = requests.get(B + '/form', timeout=5)
print('  表单 HTML:')
for ln in r.text.replace('><', '>\n<').splitlines():
    print('    ' + ln)
print('  method="post" 决定用哪种方式提交,action 决定提交到哪个地址。')
print('  每个 input 的 name 就是收到时的字段名。')

print('② GET 和 POST 差在哪')
print('|  | GET | POST |')
print('|---|---|---|')
for a, b_, c in (('参数在哪', '网址问号后面', '请求体里'),
                 ('能被收藏/分享吗', '能', '不能'),
                 ('会留在浏览器历史里吗', '会', '不会'),
                 ('有长度限制吗', '有,看浏览器和服务器', '基本没有'),
                 ('该用来干什么', '查询、翻页、搜索', '提交、修改、上传')):
    print('| %s | %s | %s |' % (a, b_, c))
print('  一句话记法:GET 是「我要看什么」,POST 是「我要你改什么」。')

print('③ 在服务端把东西收下来')
r = requests.get(B + '/add', timeout=5)
print('  GET  /add  → %d %s' % (r.status_code, r.text))
r = requests.post(B + '/add', data={'name': '六角螺栓 M8', 'qty': '120'}, timeout=5)
print('  POST /add  → %d %s' % (r.status_code, r.text))
r = requests.post(B + '/add', data={'name': '膨胀螺栓', 'qty': '很多'}, timeout=5)
print('  POST 错数据 → %d %s' % (r.status_code, r.text))
r = requests.get(B + '/form', timeout=5)
print('  GET  /form → %d,正文 %d 字节' % (r.status_code, len(r.content)))
print('  校验一定要在服务端做。前端那点检查只能挡住手滑,挡不住直接发请求的人。')

print('④ 一次请求里能拿到什么')
r = requests.get(B + '/info', params={'page': '2', 'kw': '螺栓'},
                 headers={'User-Agent': 'demo-client/1.0'}, timeout=5)
print('  GET /info?page=2&kw=螺栓')
for ln in r.text.splitlines():
    print('    ' + ln)
print('  args 是问号后面的,form 是请求体里的,两个别搞混。')

print('⑤ FAQ:为什么表单提交之后刷新会重复添加')
r = requests.post(B + '/add2', data={'name': '不该重发的'}, allow_redirects=False, timeout=5)
print('  POST /add2 → %d,Location: %s' % (r.status_code, r.headers.get('Location')))
print('  浏览器刷新时,会把上一次的 POST 原样再发一遍')
print('  标准解法是 POST-重定向-GET:处理完表单,返回一个 302 让浏览器跳到结果页。')
print('  这样刷新的是结果页(GET),不会重复提交。当前 DB 有 %d 条。' % len(DB))
srv.shutdown()

实跑输出:

--------------------------------------------------------------------
表单和它背后的那次请求
--------------------------------------------------------------------

  ① 一个能用的表单页
  --------------------------------------------------------------------
    表单 HTML:
      <form method="post" action="/add">
        品名 <input name="name" value="">
        数量 <input name="qty" value="">
        <button type="submit">提交</button>
      </form>
  method="post" 决定用哪种方式提交,action 决定提交到哪个地址。
  每个 input 的 name 就是收到时的字段名。

  ② GET 和 POST 差在哪
  --------------------------------------------------------------------

|  | GET | POST |
|---|---|---|
| 参数在哪 | 网址问号后面 | 请求体里 |
| 能被收藏/分享吗 | 能 | 不能 |
| 会留在浏览器历史里吗 | 会 | 不会 |
| 有长度限制吗 | 有,看浏览器和服务器 | 基本没有 |
| 该用来干什么 | 查询、翻页、搜索 | 提交、修改、上传 |

  一句话记法:GET 是「我要看什么」,POST 是「我要你改什么」。
  把删除操作写成 GET,等于让任何一张图片链接都能删你的数据。

  ③ 在服务端把东西收下来
  --------------------------------------------------------------------
    GET  /add  → 200 这个地址只接受表单提交
    POST /add  → 200 已收下 六角螺栓 M8 × 120,当前共 1 条
    POST 错数据 → 400 数量必须是数字,收到的是 '很多'
    GET  /form → 200,正文 158 字节
  request.method 区分这两种请求。
  同一段代码可以既显示表单又处理提交,但更常见的写法是拆成两个函数,各自只管一件事。
  校验一定要在服务端做。
  前端那点检查只能挡住手滑,挡不住直接发请求的人——上面 qty=很多 这一条,
  就是绕过页面直接 POST 进来的。

  ④ 一次请求里能拿到什么
  --------------------------------------------------------------------
    GET /info?page=2&kw=螺栓
      UA前40    'demo-client/1.0'
      主机       '127.0.0.1:62819'
      方法       'GET'
      来源IP     '127.0.0.1'
      查询参数     {'kw': '螺栓', 'page': '2'}
      表单       {}
      路径       '/info'
  一次请求带过来的东西全在这个 request 对象里:路径、查询参数、表单、请求头、来源 IP。
  做接口、做鉴权、做统计,都是从这里取。
  args 是问号后面的,form 是请求体里的,两个别搞混。把表单字段写进地址栏再读 args,会永远读到空。

  ⑤ FAQ:为什么表单提交之后刷新会重复添加
  --------------------------------------------------------------------
    浏览器刷新时,会把上一次的 POST 原样再发一遍
    服务器不知道这是「同一个提交」还是「用户又想加一条」
  标准解法是 POST-重定向-GET:处理完表单,返回一个 302 让浏览器跳到结果页。
  这样刷新的是结果页(GET),不会重复提交。
  更严格的系统还会给表单加一个一次性令牌(CSRF token),同一个令牌只能用一次。
  第 12 章的留言板会用前一种做法。

第一段把表单 HTML 拆开打印了,方便看清三个 input 和一个 button 的写法。 注意每个 input 都有 name,没有 name 的字段压根不会提交。 第三段三个请求连起来看:GET 被拒、正常 POST 成功、错数据返回 400。 那个 400 是服务端校验的结果,前端校验挡不住它——因为这次请求是直接发的,没经过页面。 第四段七行里,args 和 form 最容易搞混。这次是 GET 请求,所以 form 是空的 {}。 第五段那个 302 是重定向的状态码,Location 头告诉你跳到哪。 浏览器收到 302 就会自动再发一个 GET,所以用户看到的是结果页。 最后那句 DB 有 1 条,是 add2 加进去的那一条,说明重定向前的 POST 确实处理了, 只是处理完之后不让浏览器停在 POST 上。

30 秒实验

  1. 把演示第三段那个错数据的 qty: '很多' 改成 qty: '-5',看服务端还能不能拦住(提示: isdigit() 对负号返回 False,所以也能拦住,但它拦不住 '12.5' 这类带小数点的)。
  2. 把演示第五段的 allow_redirects=False 去掉,看 status_code 变成几、r.text 是什么。
  3. 在演示第四段加一个 print(request.get_data()),再把请求改成 POST,看请求体长什么样。

常见误区

  1. 「表单用 GET 也能提交,简单点。 」填的内容会出现在网址上,被人截图、被收藏、留在浏览器历史里。 更重要的是: 改数据用 GET 会招来 CSRF。
  2. 「前端已经校验过了,服务端不用再校验。」前端校验挡得住手滑,挡不住直接发请求的人。 服务端校验是不能省的那一道。
  3. 「args 和 form 拿到的东西差不多。」一个是问号后面的,一个是请求体里的。 把表单字段写进地址栏再读 args,会永远读到空。
自测三问(点开对答案) 1. 为什么「删除」这种操作不能用 GET? —— 因为 GET 可以被任何链接、图片、脚本触发,不需要用户点。 别人在论坛发一个 ``,你登录着又看了这个帖子, 浏览器就替你把 GET 发了。这就是 CSRF。改数据的操作必须用 POST。 2. `request.args` 和 `request.form` 有什么区别? —— `args` 是网址问号后面的查询参数,`form` 是请求体里的表单字段。 GET 请求通常只有 args,POST 请求的字段在 form 里。把表单字段写进地址栏再读 args,会读到空。 3. 表单提交成功之后按 F5 刷新,为什么会又加一条?怎么解决? —— 因为浏览器的刷新是「重发上一次的请求」,上一次是 POST,它就再 POST 一遍, 服务器分不清这是同一个提交还是新的提交。 解法是 POST-重定向-GET:处理完返回 302,浏览器跳到结果页,最后停在的是一次 GET, 刷新只是重新看一眼。

第 9 章结束。下一篇进第 10 章:把这个网站搬到云上,让别人真的能访问。

相关笔记