cgi --- 通用网关接口支持

源代码: Lib/cgi.py

3.11 版后已移除: cgi 模块已被弃用(请参阅 PEP 594 了解详情及其替代品)。


通用网关接口 (CGI) 脚本的支持模块

本模块定义了一些工具供以 Python 编写的 CGI 脚本使用。

概述

CGI 脚本是由 HTTP 服务器发起调用,通常用来处理通过 HTML <FORM><ISINDEX> 元素提交的用户输入。

在大多数情况下,CGI 脚本存放在服务器的 cgi-bin 特殊目录下。 HTTP 服务器将有关请求的各种信息(例如客户端的主机名、所请求的 URL、查询字符串以及许多其他内容)放在脚本的 shell 环境中,然后执行脚本,并将脚本的输出发回到客户端。

脚本的输入也会被连接到客户端,并且有时表单数据也会以此方式来读取;在其他时候表单数据会通过 URL 的“查询字符串”部分来传递。 本模块的目标是处理不同的应用场景并向 Python 脚本提供一个更为简单的接口。 它还提供了一些工具为脚本调试提供帮助,而最近增加的还有对通过表单上传文件的支持(如果你的浏览器支持该功能的话)。

CGI 脚本的输出应当由两部分组成,并由一个空行分隔。 前一部分包含一些标头,它们告诉客户端后面会提供何种数据。 生成一个最小化标头部分的 Python 代码如下所示:

print("Content-Type: text/html")    # HTML is following
print()                             # blank line, end of headers

后一部分通常为 HTML,提供给客户端软件来显示格式良好包含标题的文本、内联图片等内容。 下面是打印一段简单 HTML 的 Python 代码:

print("<TITLE>CGI script output</TITLE>")
print("<H1>This is my first CGI script</H1>")
print("Hello, world!")

使用cgi模块。

通过敲下 import cgi 来开始。

当你在写一个新脚本时,考虑加上这些语句:

import cgitb
cgitb.enable()

这会激活一个特殊的异常处理句柄,它将在发生任何错误时将详细错误报告显示到 Web 浏览器中。 如果你不希望向你的脚本 的用户显示你的程序的内部细节,你可以改为将报告保存到文件中,使用这样的代码即可:

import cgitb
cgitb.enable(display=0, logdir="/path/to/logdir")

在脚本开发期间使用此特性会很有帮助。 cgitb 所产生的报告提供了在追踪程序问题时能为你节省大量时间的信息。 你可以在完成测试你的脚本并确信它能正确工作之后再移除 cgitb 行。

要获取提交的表单数据,请使用 FieldStorage 类。 如果表单包含非 ASCII 字符,请使用 encoding 关键字参数并设置为文档所定义的编码格式值。 它通常包含在 HTML 文档的 HEAD 部分的 META 标记中或者由 Content-Type 标头所指明。 这会从标准输入或环境读取表单内容(取决于根据 CGI 标准设置的多个环境变量的值)。 由于它可能会消耗标准输入,它应当只被实例化一次。

FieldStorage 实例可以像 Python 字典一样来检索。 它允许通过 in 运算符进行成员检测,也支持标准字典方法 keys() 和内置函数 len()。 包含空字符串的表单字段会被忽略而不会出现在字典中;要保留这样的值,请在创建 FieldStorage 实例时为可选的 keep_blank_values 关键字形参提供一个真值。

举例来说,下面的代码(假定 Content-Type 标头和空行已经被打印)会检查字段 nameaddr 是否均被设为非空字符串:

form = cgi.FieldStorage()
if "name" not in form or "addr" not in form:
    print("<H1>Error</H1>")
    print("Please fill in the name and addr fields.")
    return
print("<p>name:", form["name"].value)
print("<p>addr:", form["addr"].value)
...further form processing here...

在这里的字段通过 form[key] 来访问,它们本身就是 FieldStorage (或 MiniFieldStorage,取决于表单的编码格式) 的实例。 实例的 value 属性会产生字段的字符串值。 getvalue() 方法直接返回这个字符串;它还接受可选的第二个参数作为当请求的键不存在时要返回的默认值。

如果提交的表单数据包含一个以上的同名字段,由 form[key] 所提取的对象将不是一个 FieldStorageMiniFieldStorage 实例而是由这种实例组成的列表。 类似地,在这种情况下,form.getvalue(key) 将会返回一个字符串列表。 如果你预计到这种可能性(当你的 HTML 表单包含多个同名字段时),请使用 getlist() 方法,它总是返回一个值的列表(这样你就不需要对只有单个项的情况进行特别处理)。 例如,这段代码拼接了任意数量的 username 字段,以逗号进行分隔:

value = form.getlist("username")
usernames = ",".join(value)

如果一个字段是代表上传的文件,请通过 value 属性访问该值或是通过 getvalue() 方法以字节形式将整个文件读入内存。 这可能不是你想要的结果。 你可以通过测试 filename 属性或 file 属性来检测上传的文件。 然后你可以从 file 属性读取数据,直到它作为 FieldStorage 实例的垃圾回收的一部分被自动关闭 (read()readline() 方法将返回字节数据):

fileitem = form["userfile"]
if fileitem.file:
    # It's an uploaded file; count lines
    linecount = 0
    while True:
        line = fileitem.file.readline()
        if not line: break
        linecount = linecount + 1

FieldStorage 对象还支持在 with 语句中使用,该语句结束时将自动关闭它们。

如果在获取上传文件的内容时遇到错误(例如,当用户点击回退或取消按钮中断表单提交时)该字段中对象的 done 属性值将被设为 -1。

文件上传标准草案考虑到了从一个字段上传多个文件的可能性(使用递归的 multipart/* 编码格式)。 当这种情况发生时,该条目将是一个类似字典的 FieldStorage 条目。 这可以通过检测它的 type 属性来确定,该属性应当是 multipart/form-data (或者可能是匹配 multipart/* 的其他 MIME 类型)。 在这种情况下,它可以像最高层级的表单对象一样被递归地迭代处理。

当一个表单按“旧”格式提交时(即以查询字符串或是单个 application/x-www-form-urlencoded 类型的数据部分的形式),这些条目实际上将是 MiniFieldStorage 类的实例。 在这种情况下,list, filefilename 属性将总是为 None

通过 POST 方式提交并且也带有查询字符串的表单将同时包含 FieldStorageMiniFieldStorage 条目。

在 3.4 版更改: file 属性会在创建 FieldStorage 实例的垃圾回收操作中被自动关闭。

在 3.5 版更改: FieldStorage 类增加了上下文管理协议支持。

更高层级的接口

前面的部分解释了如何使用 FieldStorage 类来读取 CGI 表单数据。 本部分则会描述一个更高层级的接口,它被添加到此类中以允许人们以更为可读和自然的方式行事。 这个接口并不会完全取代前面的部分所描述的技巧 --- 例如它们在高效处理文件上传时仍然很有用处。

此接口由两个简单的方法组成。 你可以使用这两个方法以通用的方式处理表单数据,而无需担心在一个名称下提交的值是只有一个还是有多个。

在前面的部分中,你已学会当你预期用户在一个名称下提交超过一个值的时候编写以下代码:

item = form.getvalue("item")
if isinstance(item, list):
    # The user is requesting more than one item.
else:
    # The user is requesting only one item.

这种情况很常见,例如当一个表单包含具有相同名称的一组复选框的时候:

<input type="checkbox" name="item" value="1" />
<input type="checkbox" name="item" value="2" />

但是在多数情况下,一个表单中的一个特定名称只对应一个表单控件。 因此你可能会编写包含以下代码的脚本:

user = form.getvalue("user").upper()

这段代码的问题在于你绝不能预期客户端会向你的脚本提供合法的输入。 举例来说,如果一个好奇的用户向查询字符串添加了另一个 user=foo 对,则该脚本将会崩溃,因为在这种情况下 getvalue("user") 方法调用将返回一个列表而不是字符串。 在一个列表上调用 upper() 方法是不合法的(因为列表并没有这个方法)因而会引发 AttributeError 异常。

因此,读取表单数据值的正确方式应当总是使用检查所获取的值是单一值还是值列表的代码。 这很麻烦并且会使脚本缺乏可读性。

一种更便捷的方式是使用这个更高层级接口所提供的 getfirst()getlist() 方法。

FieldStorage.getfirst(name, default=None)

此方法总是只返回与表单字段 name 相关联的单一值。 此方法在同一名称下提交了多个值的情况下将仅返回第一个值。 请注意所接收的值顺序在不同浏览器上可能发生变化因而是不确定的。 1 如果指定的表单字段或值不存在则此方法将返回可选形参 default 所指定的值。 如果未指定此形参则默认值为 None

FieldStorage.getlist(name)

此方法总是返回与表单字段 name 相关联的值列表。 如果 name 指定的表单字段或值不存在则此方法将返回一个空列表。 如果指定的表单字段只包含一个值则它将返回只有一项的列表。

使用这两个方法你将能写出优雅简洁的代码:

import cgi
form = cgi.FieldStorage()
user = form.getfirst("user", "").upper()    # This way it's safe.
for item in form.getlist("item"):
    do_something(item)

函数

这些函数在你想要更多控制,或者如果你想要应用一些此模块中在其他场景下实现的算法时很有用处。

cgi.parse(fp=None, environ=os.environ, keep_blank_values=False, strict_parsing=False, separator="&")

在环境中或从某个文件中解析一个查询 (文件默认为 sys.stdin)。 keep_blank_values, strict_parsingseparator 形参会被原样传给 urllib.parse.parse_qs()

cgi.parse_multipart(fp, pdict, encoding="utf-8", errors="replace", separator="&")

解析 multipart/form-data 类型(用于文件上传)的输入。 参数中 fp 为输入文件,pdict 为包含 Content-Type 标头中的其他形参的字典,encoding 为请求的编码格式。

urllib.parse.parse_qs() 那样返回一个字典:其中的键为字段名称,值为对应字段的值列表。 对于非文件字段,其值均为字符串列表。

这很容易使用,但如果你预期要上传巨量字节数据时就不太适合了 --- 在这种情况下,请改用更为灵活的 FieldStorage 类。

在 3.7 版更改: 增加了 encodingerrors 形参。 对于非文件字段,其值现在为字符串列表而非字节串列表。

在 3.9.2 版更改: 增加了 separator 形参。

cgi.parse_header(string)

将一个 MIME 标头 (例如 Content-Type) 解析为一个主值和一个参数字典。

cgi.test()

对 CGI 执行健壮性检测,适于作为主程序。 写入最小化的 HTTP 标头并以 HTML 格式来格式化提供给脚本的所有信息。

cgi.print_environ()

以 HTML 格式来格式化 shell 环境。

cgi.print_form(form)

以 HTML 格式来格式化表单。

cgi.print_directory()

以 HTML 格式来格式化当前目录。

cgi.print_environ_usage()

以 HTML 格式打印有用的环境变量列表(供 CGI 使用)。

对于安全性的关注

有一条重要的规则:如果你发起调用一个外部程序(通过 os.system(), os.popen() 或其他具有类似功能的函数),需要非常确定你不会把从客户端接收的任意字符串直接传给 shell。 这是一个著名的安全漏洞,网络中聪明的黑客可以通过它来利用容易上当的 CGI 脚本发起调用任何 shell 命令。 即使 URL 的一部分或字段名称也是不可信任的,因为请求并不一定是来自你的表单!

为了安全起见,如果你必须将从表单获取的字符串传给 shell 命令,你应当确保该字符串仅包含字母数字类字符、连字符、下划线和句点。

在 Unix 系统上安装你的 CGI 脚本

请阅读你的 HTTP 服务器的文档并咨询你所用系统的管理员来找到 CGI 脚本应当安装到哪个目录;通常是服务器目录树中的 cgi-bin 目录。

请确保你的脚本可被“其他人”读取和执行;Unix 文件模式应为八进制数 0o755 (使用 chmod 0755 filename)。 请确保脚本的第一行包含 #! 且位置是从第 1 列开始,后面带有 Python 解释器的路径名,例如:

#!/usr/local/bin/python

请确保该 Python 解释器存在并且可被“其他人”执行。

请确保你的脚本需要读取或写入的任何文件都分别是“其他人”可读取或可写入的 --- 它们的模式应为可读取 0o644 或可写入 0o666。 这是因为出于安全理由,HTTP 服务器是作为没有任何特殊权限的 "nobody" 用户来运行脚本的。 它只能读取(写入、执行)任何人都能读取(写入、执行)的文件。 执行时的当前目录(通常为服务器的 cgi-bin 目录)和环境变量集合也与你在登录时所得到的不同。 特别地,不可依赖于 shell 的可执行文件搜索路径 (PATH) 或 Python 模块搜索路径 (PYTHONPATH) 的任何相关设置。

如果你需要从 Python 的默认模块搜索路径之外的目录载入模块,你可以在导入其他模块之前在你的脚本中改变路径。 例如:

import sys
sys.path.insert(0, "/usr/home/joe/lib/python")
sys.path.insert(0, "/usr/local/lib/python")

(在此方式下,最后插入的目录将最先被搜索!)

针对非 Unix 系统的指导会有所变化;请查看你的 HTTP 服务器的文档(通常会有关于 CGI 脚本的部分)。

测试你的 CGI 脚本

很不幸,当你在命令行中尝试 CGI 脚本时它通常会无法运行,而能在命令行中完美运行的脚本则可能会在运行于服务器时神秘地失败。 但有一个理由使你仍然应当在命令行中测试你的脚本:如果它包含语法错误,Python 解释器将根本不会执行它,而 HTTP 服务器将很可能向客户端发送令人费解的错误信息。

假定你的脚本没有语法错误,但它仍然无法起作用,你将别无选择,只能继续阅读下一节。

调试 CGI 脚本

首先,请检查是否有安装上的小错误 --- 仔细阅读上面关于安装 CGI 脚本的部分可以使你节省大量时间。 如果你不确定你是否正确理解了安装过程,请尝试将此模块 (cgi.py) 的副本作为 CGI 脚本安装。 当作为脚本被发起调用时,该文件将以 HTML 格式转储其环境和表单内容。 请给它赋予正确的模式等,并向它发送一个请求。 如果它是安装在标准的 cgi-bin 目录下,应该可以通过在你的浏览器中输入表单的 URL 来向它发送请求。

http://yourhostname/cgi-bin/cgi.py?name=Joe+Blow&addr=At+Home

如果此操作给出类型为 404 的错误,说明服务器找不到此脚本 -- 也许你需要将它安装到不同的目录。 如果它给出另一种错误,说明存在安装问题,你应当解决此问题才能继续操作。 如果你得到一个格式良好的环境和表单内容清单(在这个例子中,应当会列出的有字段 "addr" 值为 "At Home" 以及 "name" 值为 "Joe Blow"),则说明 cgi.py 脚本已正确安装。 如果你为自己的脚本执行了同样的过程,现在你应该能够调试它了。

下一步骤可以是在你的脚本中调用 cgi 模块的 test() 函数:用这一条语句替换它的主代码

cgi.test()

这将产生从安装 cgi.py 文件本身所得到的相同结果。

当某个常规 Python 脚本触发了未处理的异常,(无论出于什么原因:模块名称出错、文件无法打开等),Python 解释器就会打印出一条完整的跟踪信息并退出。在 CGI 脚本触发异常时,Python 解释器依然会如此,但最有可能的是,跟踪信息只会停留在某个 HTTP 服务日志文件中,或者被完全丢弃。

幸运的是,只要执行 某些 代码,就可以利用 cgitb 模块将跟踪信息发送给浏览器。 如果你还没有这样做过,只需添加以下几行代码:

import cgitb
cgitb.enable()

然后再运行一下看;发生问题时应能看到详细的报告,或许能让崩溃的原因更清晰一些。

如果怀疑是 cgitb 模块导入的问题,可以采用一个功能更强的方法(只用到内置模块):

import sys
sys.stderr = sys.stdout
print("Content-Type: text/plain")
print()
...your code here...

这得靠 Python 解释器来打印跟踪信息。输出的类型为纯文本,不经过任何 HTML 处理。如果代码正常,则客户端会显示原有的 HTML。如果触发了异常,很可能在输出前两行后会显示一条跟踪信息。因为不会继续进行 HTML 解析,所以跟踪信息肯定能被读到。

常见问题和解决方案

  • 大部分 HTTP 服务器会对 CGI 脚本的输出进行缓存,等脚本执行完毕再行输出。这意味着在脚本运行时,不可能在客户端屏幕上显示出进度情况。

  • 请查看上述安装说明。

  • 请查看 HTTP 服务器的日志文件。(在另一个单独窗口中执行 tail -f logfile 可能会很有用!)

  • 一定要先检查脚本是否有语法错误,做法类似:python script.py

  • 如果脚本没有语法错误,试着在脚本的顶部添加 import cgitb; cgitb.enable()

  • 当调用外部程序时,要确保其可被读取。通常这意味着采用绝对路径名------ 在 CGI 脚本中, PATH 通常不会设为很有用的值。

  • 在读写外部文件时,要确保其能被 CGI 脚本归属的用户读写:通常是运行网络服务的用户,或由网络服务的 suexec 功能明确指定的一些用户。

  • 不要试图给 CGI 脚本赋予 set-uid 模式。这在大多数系统上这都行不通,出于安全考虑也不应如此。

脚注

1

请注意,新版的 HTML 规范确实注明了请求字段的顺序,但判断请求是否合法非常繁琐和容易出错,可能来自不符合要求的浏览器,甚至不是来自浏览器。