11. 万维网
超文本传输协议(HTTP, Hypertext Transfer Protocol)是一种通用机制,Cli使用HTTP向Serv,req文档,而Serv则通过HTTP向Cli提供文档。
11.0. 协议的名称为什么要以超文本(hypertext)开头?
HTTP的设计初衷,并非只是将其作为一种用于传输文件的新方法,也不是将其作为旧式文件传输协议(如FTP)的提供缓存功能的替代品。
当然,HTTP能传输书籍、图片、视频这些独立的文件,但尽管如此,HTTP的目的远不止于此。它还允许世界各地的Serv发布文档,并通过相互之间的交叉引用,形成了一张相互连接的信息网。
HTTP就是为万维网设计的。
11.1 超媒体与URL
撰写书籍时,会引用其他书籍的内容。想找到引用源,就必须先找到另一本书,然后不停翻页,找到引用的文字才行。万维网(WWW, World Wide Web,或web)所实现的,就是把寻找引用的任务,交给机器来负责。
如果一段文字"关于cookie的讨论",本来是孤立的,与外界没有联系,但是如果加了下划线,且被点击后,可跳转到所引用的文本,这段文字就成为了一个超链接(hyperlink)。文本中包含内嵌超链接的整个文档,叫做超文本(hypertext)文档。如果文档中加入了图片、声音、视频,该文档就成为了超媒体(hypermedia)。
前缀hyper表示,后面的媒介能理解文档键相互引用的机制,且能为用户生成链接。
11.1.0. 为了操作超媒体,人们发明了统一资源定位符(URL, Uniform Resource Locator)。不仅为现代的hytxt提供了一个统一的机制,还能供以前的FTP文件和Telnet-Serv使用。
在浏览器的地址栏,可看到类似的例子:
# Some sample URLs
https://www.python.org/
http://en.wikidia.org/wiki/Python_(programming_language)
http://localhost:8000/headers
ftp://ssd.jpl.nasa.gov/pub/eph/planets/README.txt
telnet://rainmaker.wunderground.com
第一个标记(https/http)为所使用的机制(scheme),指明了获取文档所使用的协议。后面跟着一个冒号和两个斜杠(://),然后是hostname,接着还有port。URL最后是一个路径(path),用于在可用服务的所有文档中,指明要获取的特定文档。
除了用于描述可从网络获取的资料外。统一资源标识符(URI, Uniform Resource Identifier)可用于标识通过网络访问的物理文档,也可作为通用的统一标识符,为实体指定PC可以识别的名字。这些name叫统一资源名(URN, Uniform Resource Name)。本书所有的内容都可叫做URL。
11.1.0.1. 当Serv需要根据user提供的参数来自动生成文档时,就需要再URL后加上一个查询字符串。由问号(?)开始,然后使用&符合来分割不同的参数。每个参数由参数名、等号、参数值组成。
https://www.google.com/search?q=apod&btnI=yes
此外,还可在URL后,加上一个以#号开始的片段(fragment),后面接上链接引用内容在页面上的具体位置。
http://tools.ietf.org/html/rfc2324#section-2.3.2
片段与URL的其他组成部分有所不同。Web浏览器在寻找片段指定的元素时,需获取路径指定的整个页面,传输的HTTP-req中并不包含关于fra的mes。Serv能从浏览器获取的URL,只包括hostname、路径、查询str。
hostname是以Host-head的形式传输的,路径和查询str则拼接在一起,组成了跟在req首行HTTP方法后的完整路径。
11.1.1. 解析与构造URL
Py标准库内置的urllib.parse模块,提供了解析并构造URL所需的工具。使用urllib.parse时,只需一个函数调用,就能将URL分解成不同的部分。较早版本的Py中,该函数的返回值就是一个元组。使用tuple()来查看元组信息,并使用int索引/在赋值语句中,使用元组拆分,来读取元组中的元素。
>>> from urllib.parse import urlsplit
>>> u = urlsplit('https://www.goole.com/search?q=apod&btnI=yes')
>>> tuple(u)
('https', 'www.google.com', '/search', 'q=apod&btnI=yes', '')
返回的元组同样支持通过属性的名称,来访问属性值,使得解析URL时,编写的代码更具可读性。
>>> u.scheme
'http'
>>> u.netloc
'www.goole.com'
>>> u.path
'/search'
>>> u.query
'q=apod&btnI=yes'
>>> u.fragment
''
表示“网络位置”(network location)的netloc属性,有由若干部分组成,但urlsplit()函数,不会在返回的元组中,将它们分解成不同的部分;相反,urlsplit()还是会在返回值中,将网络位置作为一个单独的属性。
>>> u = urlsplit('https://brandon:atigdng@localhost:8000/')
>>> u.netloc
'brandon:atigdng@localhost:8000'
>>> u.username
'brandon'
>>> u.password
'atigdng'
>>> u.hostname
'localhost'
>>> u.port
8000
11.1.1.1. 对URL进行分解,只是整个解析过程中的第一步。在构建URL的路径和查询字符串时,需要对一些字符进行转义。
如,&和#是URL的分隔符,因此不能直接在URL中使用这两个符号。此外,由于/符号是用来分割路径的,如果要在一个特定的路径中使用/符号,也必须进行转义。
URL的查询str有自己的编码规则。查询str的值,通常会包含空格,使用加号(+)来代替URL中的空格,就是一种编码方案。
如,在Google进行搜索时,如果关键字包含空格,就会用+来代替空格。如果查询str编码时,不使用+,就只能和URL其余部分的编码策略一样,使用十六进制转义码"%20"来表示空格。
假如有一个URL,用于在网站"Q&A"一节中的"TCP/IP"部分中,搜索关于packet loss的信息,如果要正确解析这个URL,就必须遵循下述步骤:
>>> from urllib.parse import parse_qs, parse_qsl, unquote
>>> u = urlsplit('http://example.com/Q&26A/TCP%2FIP?q=packet+loss')
>>> path = [unquote(s) for s in u.path.split('/')]
>>> query = parse_qsl(u.query)
>>> path
['', 'Q&26A', 'TCP/IP']
>>> query
[('q', 'packet loss')]
使用split()对路径进行分割的返回值中,一开始有一个空str。因为该路径是一个绝对路径,且以一个斜杠作为开始。
11.1.1.2. URL的查询str,允许多次指定同一个查询参数,因此解析查询str后,会返回一个元组列表,而不是简单的dir。
如果无需在编写的代码中处理这种情况,可将返回的元组列表传递给dict(),最后一次指定的参数值会作为dic中的值。
如果既想返回一个dic,又希望能多次指定同一个查询参数,那么可使用parse_qs()来代替parse_qsl()。此时会返回一个dic,dic中的值是列表。
>>> parse_qs(u.query)
{'q': ['packet loss']}
标准库中提供了反向构造URL所需的所有程序。如果已经有了path和query,Py就能通过斜杠,将路径的不同部分重新组合成完整路径,对查询str进行编码,将结果传递给urlunsplit()函数。是urlsplit()的逆过程。
>>> from urllib.parse import quote, urlencode, urlunsplit
>>> urlunsplit(('http', 'example.com', '/'.join(quote(p, safe='') for p in path),urlencode(query), ''))
'http://example.com/Q%2626A/TCP%2FIP?q=packet+loss'
标准库函数已经将所有HTTP规范都考虑进去了。
大多数网站都会精心设计表示路径的元素,无需在URL中使用不优雅的转义符。DEV将这些路径元素称为slug。
如果某个网站只允许在URL-slug中包含字母、数字、连字符、下划线,就不用再担心slug中,会包含需进行转义的斜杠符
如果确认要处理的路径的各组件中,绝对不包含用于转义的斜杠符,就可直接将该路径传递给quote()和unquote(),无需事先对其进行分割。
>>> quote('Q&A/TCP IP')
'Q%26A/TCP%20IP'
>>> unquote('Q%26A/TCP%20IP')
'Q&A/TCP IP'
quote()函数认为,正常情况下路径组件中,不会包含用于转义的斜杠符,默认参数是safe='/',表示会直接将斜杠符作为字面值。在之前的版本中,使用safe=''覆盖了该参数值。
标准库的urllib.parse还提供了一些专用方法,如urldefrag(),用于根据#符号,将片段从URL中分离出来。
11.1.2. 相对URL
文件OS的命令行,支持一个用于“更改工作目录”的命令。切换到特定的work-dir后,就可使用相对(relative)路径来搜索文件,相对路径不需以斜杠符开头。如果一个路径以斜杠符开头,就明确表示要从文件OS的根目录开始搜索文件。以斜杠符开头的路径叫做绝对(absolute)路径,绝对路径始终指向同一位置,与用户所处的work-dir无关。
$ wc -l /var/log/dmesg
977 dmesg
$ wc -l dmesg
wc:dmesg: No such file or directory
$ cd /var/log
$wc -l dmesg
977 dmesg
hyper-txt也有类似概念。如果一个文档中的所有链接都是abs-URL,这些链接会指向正确的资源。但,如果文档中包含rel-URL,就需要将文档本身的位置考虑进去了。
11.1.2.1. Py提供了一个urljoin()函数,用于处理标准中的所有相关细节。
假设从一个hyper-txt中提取出一个URL。该URL可能是相对的,也可能是绝对的。此时可将其传递给urljoin(),由urljoin()负责填充剩余信息。
urljoin()的参数顺序,和os.path.join()是一样的。第一个参数是正在乐队的文档的基地址 ,第二个参数是从该文档中提取出的相对URL,有多种方法可以重写基地址的某些部分。
>>> from urllib.parse import urljoin
>>> base = 'http://tools.ietf.org/html/rfc3986'
>>> urljoin(base, 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin(base, '.')
'http://tools.ietf.org/html/'
>>> urljoin(base, '..')
'http://tools.ietf.org/'
>>> urljoin(base, '/dailydose/')
'http://tools.ietf.org/dailydose/'
>>> urljoin(base, '?version=1.0')
'http://tools.ietf.org/html/rfc3986?version=1.0'
>>> urljoin(base, '#section-5.4')
'http://tools.ietf.org/html/rfc3986#section-5.4'
向urljoin()传入一个绝对地址是绝对安全的。urljoin()会识别出某个地址是否是绝对地址,直接将其返回,不会做任何修改。
>>> urljoin(base, 'https://www.goole.com/search?q=qpod&btnI=yes')
'https://www.goole.com/search?q=qpod&btnI=yes'
由于rel-URL无需指定使用的协议机制,如果编写网页时并不知道要使用HTTP还是HTTPS,使用rel-URL就十分方便了(即使编写网页的静态部分)。这种情况下,urljoin()只会将基地址使用的协议,复制到第二个参数提供的abs-URL中,组成完整的URL,以此作为返回值。
>>> urljoin(base, '//www.google.com/search?q=apod')
'http://www.google.com/search?q=apod'
如果在网站中使用rel-URL,有一点十分重要:一定要注意URL的最后,是否包含一个斜杠。因为,最后包含斜杠与不包含斜杠的rel-URL含义是不同的
>>> urljoin('http://tools.ietf.org/html/rfc3986', 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin('http://tools.ietf.org/html/rfc3986/','rfc7320')
'http://tools.ietf.org/html/rfc3986/rfc7320'
第一个URL表示,该req是为了显示rfc3986这一文档,而访问该文档的html-dir,此时的“当前work-dir”是html-dir
第二个URL不同。真正的文件OS中,只有dir的结尾会有斜杠,把rfc3986本身看做是正在访问的dir。所以,根据第二个URL构建出来的链接,会直接在"rfc3986/"之后,添加rel-URL参数,而不是直接在html目录下添加。
斜杠对于rel-URL的意义至关重要。
11.1.2.2. 设计web-site时,一定要确保当user提供错误的URL时,能马上将其重定向到正确的路径。
如,要访问上面例子中的第二个URL,那么IETF的web-Serv会检测到最后多加了一个斜杠,它会在res中声明了一个Location-head,给出正确的URL
每个编写过Web-Cli的DEV都会经历:rel-URL不一定相对于HTTP-req中提供的路径,如果web-site选择在res中包含一个Location-head,那么rel-URL必须相对于Location-head中提供的路径。
11.2. 超文本标记语言(HTML)
一些现行的标准,对hyper-txt的格式、使用层级样式表(CSS)确定hyper-txt样式的机制,以及JS等浏览器内嵌语言的API做了描述。其中,JS等浏览器内嵌语言,可在user与页面交互/浏览器从Serv获取更多信息时,对文档进行实时的修改。几个核心标准与资源的链接:
http://www.w3.org/TR/html5/
http://www.w3.org/TR/CSS/
https://developer.mozilla.org/en-US/docs/Web/JavaScript
https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
HTML是一种使用大量尖括号(<...>)来装饰纯文本的机制。每对尖括号都创建了一个标签(tag),如果tag开头没有斜杠,就表示文档中,某个新元素(element)的开始,否则就表示元素的结尾。下面的例子展示了一个简单的段落,该段落中包含了一个加粗的单词,和一个斜体的单词。
This is a paragraph with bold and italic words.
11.2.1. 某些标签是自包含的,不需要之后再使用对应的结束标记。如
标签,创建了段落中的一个空行。
有些DEV会把
写为
,这是从扩展标记语言(XML, Extensible Markup Language)中学习过来的,但HTML中,这并不是必需的。
在HTML中,许多东西都不是必需的。如,不一定要为所有开始标签提供对应的结束标签。当一个用
-
表示的无序列表结束时,无论
- 表示的列表元素,是否通过 标签表示元素结束,HTML解释器都会认为该无序列表包含的所有列表元素都已经结束。
-
内部用
HTML的标签是可以层层嵌套的。DEV在构建完整的Web页面时,可以不断地在HTML元素内部嵌入其他HTML元素。在构建页面的过程中,DEV大多会不断重复使用HTML定义的有限元素集合中的元素。这些元素用于表示页面上不同类型的内容。尽管HTML5标准允许DEV直接在页面上创建新元素,但DEV还是会倾向于使用标准元素。
11.2.2. 一个大型的页面可能会出现各种原因而使用
(最通用的分块形式)/
(最通用的标记连续文本的方式)这样的通用标签。
如果所有元素都使用了相同的标签,该如何使用CSS来合理地设置各元素的样式?如何使用JS来设置用户与各元素的不同交互方式?
为每个元素指定一个class。这样,HTML编写者就可以为各元素提供一个特定的标记,之后就可通过该标记来访问特定的元素了。要使用class,有两种常见的方法。
11.2.2.1. 第一种方法,设计时,为所有HTML元素都指定一个唯一的class
Provo
61°F
这样一来,对应的CSS和JS就可以通过.city和.temperature这样的选择器来引用特定的元素了。想要更细粒度一点,可使用h5.city和p.temperature。最简单的形式的CSS选择器,只需要一个标签的名称,后面加上以句点为前缀的class名称即可。两种都不是必须的。
11.2.2.2. 有时,在class为weather的
内,DEV认为他们使用
和
的目的都是唯一的,因此选择只为外层的元素指定class的值。
Provo
61°F
要在CSS/JS中引用该
内部的
和
,就需要使用更复杂的模式了。使用空格来连接外层标签的class值与内层标签的名称。
.weather h5
.weather p
11.2.2.3. 在浏览器审查元素面板里,查看到的实时文档,与最初载入页面的HTML源代码可能有所不同。取决于JS是否已经向最初载入的页面添加/删除了元素。
如果从审查元素中找到的某个元素,没有出现在最初的页面源代码中,可能需要进入网络面板,找到JS还获取并使用了哪些资源,来构建这些新增的页面元素。
11.3. 读写数据库
假设有一个简单的银行app,想要允许账户持有人使用一个Web-app相互发送账单。这个app至少需要一个存储账单的tab、插入新账单的功能,以及获取并显示与当前登录用户账户有关的所有账单的功能。
11-1中展示了一个简单的库,使用了Py标准库内置的SQLite。
# 11-1 用于创建数据库并与数据库进行通信的程序 bank.py
import os, pprint, sqlite3
from collections import namedtuple
def open_database(path='bank.db'):
new = not os.path.exists(path)
db = sqlite3.connect(path)
if new:
c = db.cursor()
c.execute('CREATE TABLE payment (id INTEGER PRIMARY KEY,'
'debit TEXT, credit TEXT, dollars INTEGER, memo TEXT)')
add_payment(db, 'brandon', 'psf', 125, 'Registration for PyCon')
add_payment(db, 'brandon', 'liz', 200, 'Payment for writing that code')
add_payment(db, 'sam', 'brandon', 25, 'Gas money-thanks for the ride!')
db.commit()
return db
def add_payment(db, debit, credit, dollars, memo):
db.cursor().execute('INSERT INTO payment (debit, credit, dollars, memo)'
' VALUES (?, ?, ?, ?)', (debit, credit, dollars, memo))
def get_payments_of(db, account):
c = db.cursor()
c.execute('SELECT * FROM payment WHERE credit = ? or debit = ?'
' ORDER BY id', (account, account))
Row = namedtuple('Row', [tup[0] for tup in c.description])
return [Row(*row) for row in c.fetchall()]
if __name__ == '__main__':
db = open_database()
pprint.pprint(get_payments_of(db, 'brandon'))
O]:
[Row(id=1, debit='brandon', credit='psf', dollars=125, memo='Registration for PyCon'),
Row(id=2, debit='brandon', credit='liz', dollars=200, memo='Payment for writing that code'),
Row(id=3, debit='sam', credit='brandon', dollars=25, memo='Gas money-thanks for the ride!')]
11.3.1. SQLite引擎将每个sql存储为磁盘上的一个独立文件,因此open_database()函数可通过检查文件是否存在来确认sql是否已经创建。如果sql已经存在,只需重新连接该sql即可。
11.3.2. 创建sql时,open_database()函数创建了一张账单表,并向tab中添加了3条示例账单信息,以便app进行展示
示例中的tab模式极其简单,只是用来满足app运行的最低要求。现实生活中,还需一个user-tab来存储username及pwd的安全散列值、包含官方银行账号的tab。款项最终会从官方银行账号提取,并支付到官方银行账号中。
本例中,一个很重要的操作值得借鉴:SQL调用的所有参数都进行了适当的转义。
程序员在向SQL这样的解释型语言提交一些特殊字符时,有时并没有进行正确的转义。这是现在安全缺陷的主要来源之一。
如果Web前端的一个恶意用户,故意在Memo字段中,包含了一些特殊SQl代码,就会造成很严重的后果。最好的保护方法就是,使用sql自身提供的功能,来正确的引用数据,而不使用自己构建的程序逻辑。
为了完成这一过程,11-1在代码中所有需要插入参数的地方,都向SQLite提供了一个问号(?),而没有自己进行转义/插入参数
11.3.3. 本例中另一个重要操作就是为原始的sql-row赋予了更丰富的语义。fetchall()方法并不是sqlite3独有的,它是DB-API2.0的一部分。为支持互操作性,所有现代Py-DB连接接口,都支持DB-API 2.0。此外,fetchall()没有为sql查询返回的每一行结果返回一个对象,甚至没有返回一个dic,而是为每一行返回了一个tuple
(1, 'brandon', 'psf', 125, 'Registration for PyCon')
直接操作这些原始的tuple结果是糟糕的做法。
代码中,“欠款账户”/“已付账款”这样的概念,会以row[2]/row[3]这样的形式来表示,大大降低了可读性。因此,bank.py使用了一个简单的namedtuple类,该类同样支持使用row.credit和row.dollars这样的属性名。
11.3.4. 尽管每次调用SELECT时,都需要新建一个类,这在效率上并不是最优的,但却能只用一两行简单的代码,就提供了Web-app所需的语义,使得能把精力集中在Web-app本身的编写上。
11.4. 一个糟糕的Web应一共程序(使用Flask)
首先应该学习的是app_insecure.py,仔细考虑,该代码是否是糟糕且不可信?会不会导致安全威胁,损害公众的利益?
import bank
from flask import Flask, redirect, request, url_for
from jinja2 import Environment, PackageLoader
app = Flask(__name__)
get = Environment(loader=PackageLoader(__name__, 'templates')).get_template
@app.route('/login', methods=['GET', 'POST'])
def login():
username = request.form.get('username', '')
password = request.form.get('password', '')
if request.method == 'POST':
if (username, password) in [('brandon', 'atigdng'), ('sam', 'xyzzy')]:
response = redirect(url_for('index'))
response.set_cookie('username', username)
return response
return get('login.html').render(username=username)
@app.route('/logout')
def logout():
response = redirect(url_for('login'))
response.set_cookie('username', '')
return response
@app.route('/')
def index():
username = request.cookies.get('username')
if not username:
return redirect(url_for('login'))
payments = bank.get_payments_of(bank.open_database(), username)
return get('index.html').render(payments=payments, username=username,
flash_messages=request.args.getlist('flash'))
def pay():
username = request.cookies.get('username')
if not username:
return redirect(url_for('login'))
account = request.form.get('account', '').strip()
dollars = request.form.get('dollars', '').strip()
memo = request.form.get('memo', '').strip()
complaint = None
if request.method == 'POST':
if account and dollars and dollars.isdigit() and memo:
db = bank.open_database()
bank.add_payment(db, username, account, dollars, memo)
db.commit()
return redirect(url_for('index', flash='Payment successful'))
complaint = ('Dollars must be an integer' if not dollars.isdigit()
else 'Please fill in all three fields')
return get('pay.html').render(complaint=complaint, account=account,
dollars=dollars, memo=memo)
if __name__ == '__main__':
app.debug = True
app.run()
上述代码是危险的,无法抵御现代Web上针对向量的重要攻击。
代码中的弱点,来自于数据处理过程中发生的错误,与网站是否合理进行了TLS防止网络窃听无关。可以假定该网站已经采取了加密保护,如在前端使用了一个rev-prx-Serv。会考虑攻击者在无法获取特定user与app间传递的数据时,所能进行的恶意行为。
11.4.1. 该app使用了Flask框架,来处理Py-Web-app的一些基本操作:
1)请求app没有定义的页面时,返回404;
2)从HTML-form中解析数据;
3)使用模板生成HTML文本/重定向到另一个URL,来简化HTTP-res的生成过程。
更多关于Flask的信息,可访问http://flask.pocoo.org/的文档
假设上面的代码是有不熟悉Web的DEV编写的,知道使用模板语言,可以方便向HTML中加入自定义的文本,因此了解加载并运行Jinja2的方法。此外,发现Flask微型框架流行程度仅次于Django,且喜欢Flask能将一个app放在一个单独的文件中这一特性,决定尝试使用Flask。
11.4.1.1. 从上往下阅读代码,可依次看到login()和logout()。这个app没有真正的user-DB,因此login()中直接硬编码了两个虚拟的username和pwd。从login()中可以看出,登录和登出会导致cookie的创建和删除。如果后续的req中提供了cookie,那么Serv会认为cookie标记的user是授权user。
11.4.1.2. 另外两个页面都不允许非授权user查看。index()和pay()都会先查询cookie,如果没有找到cookie值,就会重定向到登录页面。
除了检查user是否已经login外,login后的视图只有两行代码。
11.4.1.3. 首先从数据拉取当前user的账单信息,然后与其他信息组合起来一起传递给HTML页面模板。需要向即将生成的页面提供username。
但代码中为什么要检查名为'flash'的URL呢(Flask通过request.args-dic来提供URL参数)?
pay()函数中,支付成功后,user会被重定向到index页面,此时user可能需要一些提示,以确认自己提交的form得到了正确的处理。这个功能是通过Web框架的flash-mes来完成的。flash-mes会显示在页面的顶部(这里的flash与Adobe Flash没有任何关系,只是表示user下次访问该页面时,mes会像flash广告一样呈现给user,然后消失)。在该Web-app中的第一个版本中,只是简单地将flash-mes设计为URL中的一个查询str。
http://example.com/?flash=Payment+successful
对于经常阅读Web-app的DEV来说,pay()的剩余部分就很熟悉了:检查form是否成功提交,如果成功,就进行一些操作。
user/浏览器有时可能会提供/漏掉一些form参数,因此很谨慎地在代码中使用request.form-dic的get()进行了处理。如果某个键缺失的话,就返回一个默认值(空字符串'')
如果满足条件,pay()就会将该账单永久添加到DB中;否则,将form返回给user。
如果user已经填写了一些mes,那么上面的代码不会直接将user已经填写的mes丢弃,也不会返回空白的form和err-mes,而是将user已经填写的值传回给模板。这样,在user看到的页面中,就能重新显示他们已经填写过的值了。
11.4.1.4. 把一些通用的HTML设计元素提取到了一个基本模板中,因此实际上有4个模板,这也是DEV在构建多页面网站时,最常使用的一种模式。
11-3所示的模板定义了一个页面框架,其他模板可以向base.html中的几个地方插入页面标题和页面body。标题可使用两次,一次是在
元素中,另一次是在元素中。
11-3 base.html页面的Jinja2模板
{% block title %}{% endblock %}
{{ self.title() }}
{% block body %}{% enblock %}