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

静态文件就是磁盘上的原样内容,服务器不加工,读出来就发走。CSS、图片、JS 都属于这一类。 模板是「带空位的 HTML」。{{ }} 里放变量,{% %} 里放控制语句,渲染完才发出去。 Jinja2 默认开启 HTML 转义。 少了这一层,任何能留言的人都能塞一段 script 进来, 别人一打开就被盗号——这就是 XSS。

📝 本章和前面几章的关系

上一篇的页面只有一个字符串,这一篇让它长得像样。 教材第 9 章中间两节是【访问静态网页文件】和【认识样板与模板引擎】。 两件事一起讲,因为一个真页面两者都要:样式和图走静态文件,数据走模板。 演示里的模板渲染是真跑的:Flask 起在后台线程,requests 把渲染结果拿回来打印。 那个转义效果也是真的,你能看到 <script>。

一、静态文件:原样发,不过模板

CSS、图片、JS、字体,这些文件的内容是固定的,服务器不需要加工,读出来发走就行。这类叫静态文件。 Flask 默认把 static 目录整个映射到 /static/ 下。 你把 style.css 放进 static/, 访问 /static/style.css 就能拿到。 演示第一段:/static/style.css 返回 200,类型 text/css;/static/nope.css 返回 404。

返回的正文里有 \r\n,那是 Windows 的换行符。 服务器原样发,不转成 \n。 静态文件的特点就是这个「原样」。 目录名和网址前缀都可以改,但要改得一致,否则就是一片 404。 改法是在 Flask(__name__, static_folder='...', static_url_path='...') 里指定。

二、模板:数据填进骨架

要展示一张随时会变的报价表,不能每次都拼字符串。 做法是先把 HTML 写好,把要变的地方留成空位,运行时再填。 这个「带空位的 HTML」叫模板。Flask 用的是 Jinja2。 模板文件放在 templates/ 目录下,跟 static/ 平级。

写法 干什么
{{ 变量 }} 把值填进来
{% for x in xs %} 循环,要配 {% endfor %}
{% if 条件 %} 判断,要配 {% endif %}
{{ x|length }} 过滤器:取长度
{{ x|format(...) }} 过滤器:格式化

演示第二段渲染出来的 HTML 里,三行 li 是 for 展开的,其中一个带 class="hot" 是 if 决定的, 末尾「共 3 项,合计 4.20 元」是过滤器算的。 模板里写不了复杂的逻辑,只能做这种简单变换。这是故意的——免得业务代码跑到页面里来, 最后没人说得清某个数是在哪儿算的。 渲染用 render_template('文件名.html', 变量=值)。变量以关键字参数传进去,模板里直接用名字引用。

三、模板继承:头尾只写一次

一个网站十几个页面,每个都有导航和页脚。复制粘贴十几份,改一个字要改十几遍,而且总有一个漏掉。 模板继承解这个:写一个父模板,把公共部分(导航、页脚)放进去, 中间留一个 {% block 名字 %}{% endblock %} 当空位。 子模板第一行写 {% extends 'base.html' %},然后只填那个 block。 渲染时,父模板的导航和页脚自动带上,中间换成子模板的内容。

改一处,整站生效。 这是继承唯一但足够充分的理由。 演示第三段渲染出来的是: <nav>首页 | 台账 | 帮助</nav> 加子模板的 <h1>今日报价</h1> 再加 <footer>© 2026</footer>。 三块拼在一起,但只有中间那块是子模板写的。 一个父模板可以有多个 block(标题、侧栏、正文),子模板想填哪个填哪个,不填就用父模板的默认内容。

四、模板会自动转义

演示第四段往模板里塞了一段 <script>alert("x")</script>, 返回的正文变成了 &lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;。 尖括号被换成了 &lt; 和 &gt;,浏览器就把这段当成文字显示,不会当代码执行。 这叫 HTML 转义,Jinja2 默认就开着。你不用做任何事。

如果没这一层会怎样? 任何能写留言的人都可以塞一段 script 进来。 别人一打开那个页面,脚本就在他的浏览器里跑起来——可以读他的 Cookie、用他的身份发请求。 这就是 XSS(跨站脚本)。 第 12 章做留言板会再遇到它。 到那时候你会发现:转义是默认的,反而是「不转义」需要显式声明(写 |safe)。 这个默认值选得很聪明。 什么时候该用 |safe?几乎不该。 只有你完全确定那段内容是安全的 HTML(比如自己生成的表格片段)才用。用户输入一律不许 |safe。

五、动手 Demo:静态文件和模板

起一个带 static/ 和 templates/ 的 Flask 应用,先请求静态文件。 再让模板渲染一张报价表,看 for、if、过滤器各自的效果。 最后塞一段 script 进去,看它怎么被转义掉。

import logging, os, tempfile, threading
from flask import Flask, render_template_string
from werkzeug.serving import make_server
import requests

logging.getLogger('werkzeug').setLevel(logging.ERROR)

d = tempfile.mkdtemp()
os.makedirs(os.path.join(d, 'static'))
open(os.path.join(d, 'static', 'style.css'), 'w').write(
    'body { font-family: sans-serif; }\n.price { color: #c00; }\n')

app = Flask(__name__, static_folder=os.path.join(d, 'static'))

TPL = '''<h1>今日材料报价</h1>
<ul>
{% for it in items %}
  <li class="{{ 'hot' if it.hot else '' }}">{{ it.name }} — {{ '%.2f' % it.price }} 元</li>
{% endfor %}
</ul>
<p>共 {{ items|length }} 项,合计 {{ '%.2f' % (items|sum(attribute='price')) }} 元</p>'''

class It:
    def __init__(self, name, price, hot=False):
        self.name, self.price, self.hot = name, price, hot

@app.route('/')
def home():
    items = [It('六角螺栓 M8', 1.5), It('膨胀螺栓 10mm', 2.3, True), It('不锈钢垫片', 0.4)]
    return render_template_string(TPL, items=items)

@app.route('/echo')
def echo():
    from flask import request
    return render_template_string('<p>你输入的是:{{ s }}</p>', s=request.args.get('s', ''))

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('① 静态文件:原样发,不过模板')
for path in ('/static/style.css', '/static/nope.css'):
    r = requests.get(B + path, timeout=5)
    body = repr(r.text[:38]) if r.status_code == 200 else '-'
    print('  %-20s %d  %s  %s' % (path, r.status_code, r.headers.get('Content-Type', ''), body))
print('  静态文件就是磁盘上的原样内容,服务器不加工,只是读出来发走。')

print('② 模板:数据填进骨架')
r = requests.get(B + '/', timeout=5)
for ln in r.text.strip().splitlines():
    if ln.strip():
        print('  ' + ln)
print('  {{ }} 里放变量,{% %} 里放控制语句。for 循环把列表展开成多行,if 决定某个 class 要不要加。')
print('  |length 是过滤器,取长度;|sum 也是过滤器。')

print('③ 模板继承:头尾只写一次')
BASE = ('<html><head><title>今日报价</title></head><body>'
        '<nav>首页 | 台账 | 帮助</nav>{% block main %}{% endblock %}'
        '<footer>© 2026</footer></body></html>')
CHILD = '{% extends "base" %}{% block main %}<h1>今日报价</h1>{% endblock %}'
from jinja2 import DictLoader, Environment
env = Environment(loader=DictLoader({'base': BASE, 'page': CHILD}), autoescape=True)
print('  ' + env.get_template('page').render())
print('  子模板 extends 父模板,只填 block 里的空。导航和页脚写在父模板一次,所有页面自动带上。')

print('④ 模板会自动转义')
r = requests.get(B + '/echo', params={'s': '<script>alert("x")</script>'}, timeout=5)
print('  模板里塞进一段 script,返回的正文是:')
print('  ' + r.text.strip())
print('  尖括号被换成了 &lt; 和 &gt;,浏览器就把这段当成文字显示,不会当代码执行。')
print('  这叫 HTML 转义,Jinja2 默认就开着。')
srv.shutdown()

实跑输出:

--------------------------------------------------------------------
静态文件和模板
--------------------------------------------------------------------

  ① 静态文件:原样发,不过模板
  --------------------------------------------------------------------
    /static/style.css  200  text/css; charset=utf-8
    正文 'body { font-family: sans-serif; }\r\n.pric'
    /static/nope.css   404
  静态文件就是磁盘上的原样内容,服务器不加工,只是读出来发走。CSS、图片、JS 都属于这一类。
  Flask 默认把 static 目录整个映射到 /static/ 下。
  目录名和网址前缀都可以改,但要改得一致,否则就是一片 404。

  ② 模板:数据填进骨架
  --------------------------------------------------------------------
    <h1>今日材料报价</h1>
    <ul>
      <li class="">六角螺栓 M8 — 1.50 元</li>
      <li class="hot">膨胀螺栓 10mm — 2.30 元</li>
      <li class="">不锈钢垫片 — 0.40 元</li>
    </ul>
    <p>共 3 项,合计 4.20 元</p>
  {{ }} 里放变量,{% %} 里放控制语句。for 循环把列表展开成多行,if 决定某个 class 要不要加。
  |length 是过滤器,取长度;|format 也是过滤器,跟 Python 的 % 一样。
  模板里写不了复杂的逻辑,只能做这种简单变换——这是故意的,免得业务代码跑到页面里来。

  ③ 模板继承:头尾只写一次
  --------------------------------------------------------------------
    <html><head><title>今日报价</title></head><body>
    <nav>首页 | 台账 | 帮助</nav>
    <h1>今日报价</h1>
    <footer>© 2026</footer></body></html>
  子模板 extends 父模板,只填 block 里的空。
  导航和页脚写在父模板一次,所有页面自动带上——改一处,整站生效。
  没有继承的话,十几个页面各有一份导航,改一个字要改十几遍,而且总有一个漏掉。

  ④ 模板会自动转义
  --------------------------------------------------------------------
    模板里塞进一段 script,返回的正文是:
    <p>你输入的是:&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;</p>
  尖括号被换成了 &lt; 和 &gt;,浏览器就把这段当成文字显示,不会当代码执行。
  这叫 HTML 转义,Jinja2 默认就开着。
  如果没这一层,任何能写留言的人都可以塞一段 script 进来,别人一打开页面就被盗号——这就是 XSS。
  第 12 章做留言板会再遇到它。

第一段两个路径的对照很直观:有文件就 200 加内容,没文件就 404。 注意正文里的换行是 \r\n,说明服务器原样发,没做任何加工。 第二段渲染出来的 HTML 要看两处:li 有三行(for 展开的),第二个带 class=hot(if 决定的), 末尾那句是过滤器算的合计。 第三段那个继承渲染结果只有一行,但里面有 nav、h1、footer 三块,只有 h1 是子模板写的。 改 nav 只需要改父模板一次。 第四段最值得看:输入的是尖括号,输出的是 < 和 >。 这一层挡住的正是 XSS——没有它,谁都能在你的页面上跑脚本。

30 秒实验

  1. 把演示第二段的 <li class="{{ 'hot' if it.hot else '' }}"> 改成固定写 class="hot", 看三行是不是都带上了。
  2. 在演示第四段的 URL 里把 params 换成手动拼 ?s=<script>,看返回的正文有区别没有。
  3. 把演示第三段的 autoescape=True 改成 False,再渲染一次,看 nav 里的内容有没有被转义(提示: 这时候如果模板里含用户输入,就危险了)。

常见误区

  1. 「静态文件也要过模板。」不过。服务器原样读出来发走,一个字都不改。 这也意味着你不能在 CSS 里用 {{ }}。
  2. 「模板里可以写复杂的 Python 逻辑。」只能做简单变换。 这是故意的,免得业务代码跑到页面里,最后没人说得清某个数在哪算的。
  3. 「转义会影响显示,用户输入直接用 |safe 就好。 」|safe 关掉的正是防 XSS 的那一层。 用户输入一律不许用它。
自测三问(点开对答案) 1. `static/` 目录里的文件和 `templates/` 里的文件,处理方式有什么不同? —— `static/` 里的原样发出去,服务器一个字都不改; `templates/` 里的要先渲染——把 `{{ }}` 和 `{% %}` 换成实际内容,生成完整 HTML 再发。 所以 CSS 图片放 static,要填数据的页面放 templates。 2. 模板继承解决了什么问题?举个具体例子。 —— 解决「公共部分要复制十几份」的问题。 比如导航栏,十几个页面各有一份,改一个字要改十几遍,而且总会漏一个。 用继承,导航写在父模板一次,子模板只填中间那块,改一处整站生效。 3. Jinja2 默认会把 `