二十一、项目 2:绘制漂亮的图片
在这个项目中,您将学习如何用 Python 创建图形。更具体地说,您将创建一个带有图形的 PDF 文件,帮助您可视化从文本文件中读取的数据。虽然您可以从常规的电子表格中获得这样的功能,但是 Python 提供了更强大的功能,当您使用第二个实现并自动从互联网上下载数据时,您将会看到这一点。
在前一章,我们看了 HTML 和 XML——这是另一个缩写,我猜你可能很熟悉:PDF,便携文档格式的缩写。PDF 是 Adobe 创建的一种格式,可以用图形和文本表示任何类型的文档。PDF 文件实际上是不可编辑的(比如说,Microsoft Word 文件可能是可编辑的),但大多数平台都有免费的阅读器软件,无论您使用哪种阅读器或在哪个平台上,PDF 文件看起来都应该是一样的(与 HTML 不同,HTML 可能没有正确的字体,您通常必须将图片作为单独的文件发送,等等)。
有什么问题?
Python 非常适合分析数据。利用它的文件处理和字符串处理功能,从数据文件创建某种形式的报告可能比在普通的电子表格中创建类似的东西更容易,特别是当您想要做的事情需要一些复杂的编程逻辑时。
你已经看到(在第三章)如何使用字符串格式得到漂亮的输出——例如,如果你想打印列中的数字。然而,有时纯文本是不够的。(就像他们说的,一张图胜过千言万语。)在这个项目中,您将学习 ReportLab 包的基础知识,它使您能够像以前创建纯文本一样轻松地创建 PDF 格式(和其他一些格式)的图形和文档。
当你在这个项目中玩概念的时候,我鼓励你找到一些你感兴趣的应用。我选择使用关于太阳黑子的数据(来自美国国家海洋和大气管理局的太空天气预测中心),并根据这些数据创建一个线图。
该程序应能够执行以下操作:
- 从互联网下载数据文件
- 解析数据文件并提取感兴趣的部分
- 基于数据创建 PDF 图形
与前一个项目一样,第一个原型可能无法完全满足这些目标。
有用的工具
这个项目中的关键工具是图形生成包。相当多的这样的软件包是可用的;我之所以选择 ReportLab,是因为它易于使用,并且具有丰富的 PDF 图形和文档生成功能。如果你想超越基础,你可能还想考虑 PYX 图形包( http://pyx.sf.net
),它真的很强大,并且支持基于 TEX 的排版。
要获取 ReportLab 软件包,请访问位于 http://www.reportlab.org
的官方网站。在那里您可以找到软件、文档和示例。您可以从网站下载或通过pip
安装该库。完成后,您应该可以导入reportlab
模块,如下所示:
>>> import reportlab
>>>
Note
虽然我向您展示了一些 ReportLab 功能在这个项目中是如何工作的,但是还有更多的功能可用。要了解更多信息,我建议您从 ReportLab 网站上获取手册。它们可读性很强,而且比这一章可能包含的内容要广泛得多。
准备
在你开始编程之前,你需要一些数据来测试你的程序。我(相当随意地)选择了使用关于太阳黑子的数据,这些数据可以从太空天气预测中心的网站上获得( http://www.swpc.noaa.gov
)。你可以在 ftp://ftp.swpc.noaa.gov/pub/weekly/Predict.txt
找到我在例子中使用的数据。
这个数据文件每周更新,包含有关太阳黑子和射电流量的信息。(别问我那是什么意思。)一旦有了这个文件,就可以开始处理这个问题了。
下面是文件的一部分,让您了解数据的样子:
# Predicted Sunspot Number And Radio Flux Values
# With Expected Ranges
#
# -----Sunspot Number------ ----10.7 cm Radio Flux----
# YR MO PREDICTED HIGH LOW PREDICTED HIGH LOW
#--------------------------------------------------------------
2016 03 30.9 31.9 29.9 96.9 97.9 95.9
2016 04 30.5 32.5 28.5 96.1 97.1 95.1
2016 05 30.4 33.4 27.4 94.9 96.9 92.9
2016 06 30.3 35.3 25.3 93.2 96.2 90.2
2016 07 30.2 35.2 25.2 91.6 95.6 87.6
2016 08 30.0 36.0 24.0 90.3 94.3 86.3
2016 09 29.8 36.8 22.8 89.5 94.5 84.5
2016 10 30.0 37.0 23.0 88.9 94.9 82.9
2016 11 30.1 38.1 22.1 88.1 95.1 81.1
2016 12 30.5 39.5 21.5 87.8 95.8 79.8
首次实施
在第一个实现中,让我们将数据作为元组列表放入源代码中。这样,就很容易接近了。这里有一个你可以如何做的例子:
data = [
# Year Month Predicted High Low
(2016, 03, 30.9, 31.9, 29.9),
(2016, 04, 30.5, 32.5, 28.5),
# Add more data here
]
这样一来,让我们看看如何将数据转化为图形。
使用 ReportLab 绘图
ReportLab 由许多部分组成,使您能够以多种方式创建输出。生成 pdf 最基本的模块是pdfgen
。它包含一个Canvas
类,带有几个用于绘图的底层方法。例如,要在名为c
的Canvas
上画线,可以调用c.line
方法。
我们将使用更高级的图形框架(在包reportlab.graphics
及其子模块中),这将使我们能够创建各种形状对象,并将它们添加到一个Drawing
对象中,您可以稍后以 PDF 格式输出到一个文件中。
清单 21-1 展示了一个绘制字符串“Hello,world!”在一个 100 × 100 点的 PDF 图形中间。(你可以在图 21-1 中看到结果。)基本结构如下:创建一个给定大小的绘图,创建具有某些属性的图形元素(在本例中是一个String
对象),然后将这些元素添加到绘图中。最后,绘图被渲染为 PDF 格式并保存到文件中。
图 21-1。
A simple ReportLab figure
from reportlab.graphics.shapes import Drawing, String
from reportlab.graphics import renderPDF
d = Drawing(100, 100)
s = String(50, 50, 'Hello, world!', textAnchor='middle')
d.add(s)
renderPDF.drawToFile(d, 'hello.pdf', 'A simple PDF file')
Listing 21-1.A Simple ReportLab Program
(hello_report.py)
对renderPDF.drawToFile
的调用将您的 PDF 文件保存到当前目录中的一个名为hello.pdf
的文件中。
String
构造函数的主要参数是它的 x 和 y 坐标及其文本。此外,您可以提供各种属性(如字体大小、颜色等)。在这种情况下,我提供了一个textAnchor
,它是字符串中应该放在坐标给定点上的部分。
构建一些折线
要创建太阳黑子数据的折线图,你需要创建一些线条。事实上,您需要创建几条链接的线。ReportLab 对此有一个特殊的类:PolyLine
。
创建一个PolyLine
,用一个坐标列表作为它的第一个参数。这个列表的形式是[(x0, y0), (x1, y1), ...]
,每对 x 和 y 坐标在PolyLine
上形成一个点。简单的PolyLine
见图 21-2 。
图 21-2。
PolyLine([(0, 0), (10, 0), (10, 10), (0, 10)])
要制作折线图,必须为数据集中的每一列创建一条折线。这些折线中的每个点将由一个时间(由年和月构成)和一个值(太阳黑子的数量,取自相关列)组成。要获得其中一列(值),列表理解可能很有用。
pred = [row[2] for row in data]
这里,pred
(对于“预测的”)将是数据的第三列中的所有值的列表。您可以对其他列使用类似的策略。(每一行的时间都需要根据年和月来计算,例如,年+月/12。)
一旦有了值和时间戳,就可以将折线添加到绘图中,如下所示:
drawing.add(PolyLine(list(zip(times, pred)), strokeColor=colors.blue))
当然,没有必要设置描边颜色,但是这样可以更容易区分线条。(注意如何使用zip
将时间和值组合成一个元组列表。)
编写原型
现在你已经有了编写程序第一个版本所需要的东西。源代码如清单 21-2 所示。
from reportlab.lib import colors
from reportlab.graphics.shapes import *
from reportlab.graphics import renderPDF
data = [
# Year Month Predicted High Low
(2007, 8, 113.2, 114.2, 112.2),
(2007, 9, 112.8, 115.8, 109.8),
(2007, 10, 111.0, 116.0, 106.0),
(2007, 11, 109.8, 116.8, 102.8),
(2007, 12, 107.3, 115.3, 99.3),
(2008, 1, 105.2, 114.2, 96.2),
(2008, 2, 104.1, 114.1, 94.1),
(2008, 3, 99.9, 110.9, 88.9),
(2008, 4, 94.8, 106.8, 82.8),
(2008, 5, 91.2, 104.2, 78.2),
]
drawing = Drawing(200, 150)
pred = [row[2]-40 for row in data]
high = [row[3]-40 for row in data]
low = [row[4]-40 for row in data]
times = [200*((row[0] + row[1]/12.0) - 2007)-110 for row in data]
drawing.add(PolyLine(list(zip(times, pred)), strokeColor=colors.blue))
drawing.add(PolyLine(list(zip(times, high)), strokeColor=colors.red))
drawing.add(PolyLine(list(zip(times, low)), strokeColor=colors.green))
drawing.add(String(65, 115, 'Sunspots', fontSize=18, fillColor=colors.red))
renderPDF.drawToFile(drawing, 'report1.pdf', 'Sunspots')
Listing 21-2.The First Prototype for the Sunspot Graph Program (sunspots_proto.py)
如您所见,我已经调整了值和时间戳以获得正确的定位。结果图如图 21-3 所示。
图 21-3。
A simple sunspot graph
虽然制作了一个可以工作的程序是令人高兴的,但显然还有改进的余地。
第二次实施
那么,我们从原型中学到了什么?我们已经弄清楚了如何用 ReportLab 画图的基础。我们还看到了如何提取数据,以便很好地绘制图表。然而,该计划也有一些弱点。为了正确定位,我必须对值和时间戳添加一些特别的修改。并且程序实际上并不从任何地方获取数据(或者,更确切地说,它从程序本身内部的列表中“获取”数据,而不是从外部来源读取数据)。
与项目 1 不同(在第二十章),第二个实现不会比第一个大得多或复杂得多。这将是一个渐进的改进,使用 ReportLab 的一些更合适的功能,并从互联网上获取数据。
获取数据
正如你在第十四章看到的,你可以用标准模块urllib
通过互联网获取文件。它的函数urlopen
的工作方式与open
非常相似,但是它使用 URL 而不是文件名作为参数。当你打开文件并阅读其内容后,你需要过滤掉你不需要的内容。该文件包含空行(仅由空格组成)和以一些特殊字符(#
和:
)开头的行。程序应该忽略这些。(请参阅本章前面“准备工作”一节中的示例文件片段。)
假设 URL 保存在一个名为URL
的变量中,并且变量COMMENT_CHARS
已经被设置为字符串'#:'
,您可以得到一个行列表(如我们的原始程序所示),如下所示:
data = []
for line in urlopen(URL).readlines():
line = line.decode()
if not line.isspace() and not line[0] in COMMENT_CHARS:
data.append([float(n) for n in line.split()])
前面的代码将包括数据列表中的所有列,尽管您对与无线电通量有关的列并不特别感兴趣。但是,当您提取真正需要的列时,这些列将被过滤掉(就像您在原始程序中所做的那样)。
Note
如果您正在使用自己的数据源(或者如果在您阅读本文时,黑子文件的数据格式已经改变),那么您当然需要相应地修改这段代码。
使用 LinePlot 类
如果你认为获取数据出奇的简单,画一个更漂亮的线图也不是什么挑战。在这种情况下,最好浏览一下文档(在这种情况下,是 ReportLab 文档),看看是否已经存在可以满足您需要的功能,这样您就不需要自己实现它了。幸运的是,有这样一个东西:来自模块reportlab.graphics.charts.lineplots
的LinePlot
类。当然,你可以从这个开始,但是本着快速原型的精神,你只是利用手边的东西来看看你能做什么。现在是时候更进一步了。
在没有任何参数的情况下实例化了LinePlot
,然后在将它添加到Drawing
之前设置它的属性。您需要设置的主要属性有x
、y
、height
、width
和data
。前四个应该是不言自明的;后者只是一个点列表,每个点列表都是一个元组列表,就像您在PolyLine
中使用的那样。
最后,让我们设置每条线的笔画颜色。最终代码如清单 21-3 所示。结果图(当然,不同的输入数据看起来会有些不同)如图 21-4 所示。
图 21-4。
The final sunspot graph
from urllib.request import urlopen
from reportlab.graphics.shapes import *
from reportlab.graphics.charts.lineplots import LinePlot
from reportlab.graphics.charts.textlabels import Label
from reportlab.graphics import renderPDF
URL = 'ftp://ftp.swpc.noaa.gov/pub/weekly/Predict.txt'
COMMENT_CHARS = '#:'
drawing = Drawing(400, 200)
data = []
for line in urlopen(URL).readlines():
line = line.decode()
if not line.isspace() and line[0] not in COMMENT_CHARS:
data.append([float(n) for n in line.split()])
pred = [row[2] for row in data]
high = [row[3] for row in data]
low = [row[4] for row in data]
times = [row[0] + row[1]/12.0 for row in data]
lp = LinePlot()
lp.x = 50
lp.y = 50
lp.height = 125
lp.width = 300
lp.data = [list(zip(times, pred)),
list(zip(times, high)),
list(zip(times, low))]
lp.lines[0].strokeColor = colors.blue
lp.lines[1].strokeColor = colors.red
lp.lines[2].strokeColor = colors.green
drawing.add(lp)
drawing.add(String(250, 150, 'Sunspots',
fontSize=14, fillColor=colors.red))
renderPDF.drawToFile(drawing, 'report2.pdf', 'Sunspots')
Listing 21-3.The Final Sunspot Program (sunspots.py)
进一步探索
Python 提供了许多图形和绘图包。ReportLab 的一个很好的替代品是 PYX,我在本章前面提到过。使用 ReportLab 或 PYX(或其他一些包),您可以尝试将自动生成的图形合并到文档中(也许还会生成其中的一部分)。你可以使用第二十章中的一些技巧来给文本添加标记。如果你想创建一个 PDF 文档,那么 Platypus,ReportLab 的一部分,是有用的。(您也可以将 PDF 图形与一些排版系统(如 LATEX)集成在一起。)如果你想创建网页,也可以使用 Python 创建 pixmap 图形(如 GIF 或 PNG)——只需在网上搜索相关主题。
如果您的主要目标是绘制数据(这正是我们在这个项目中所做的),您有许多 ReportLab 和 PYX 的替代方案。一个很好的选择是 Matplotlib/pylab ( http://matplotlib.org
),但是还有很多其他类似的包。
什么现在?
在第一个项目中,您学习了如何通过创建一个可扩展的解析器将标记添加到纯文本文件中。在下一个项目中,您将学习如何使用 Python 标准库中已经存在的解析器机制来分析标记文本(XML 格式)。该项目的目标是使用一个 XML 文件来指定一个完整的网站,然后由您的程序自动生成(带有文件、目录、添加的页眉和页脚)。您在下一个项目中学到的技术将普遍适用于 XML 解析,鉴于 XML 的普遍性,这不会有什么坏处。
二十二、项目 3:适合所有场合的 XML
我在项目 1 中简单提到了 XML。现在是时候更详细地检查它了。在这个项目中,您将看到如何使用 XML 来表示多种数据,以及如何使用 Simple API for XML(SAX)来处理 XML 文件。这个项目的目标是从一个描述各种网页和目录的 XML 文件生成一个完整的网站。
在本章中,我假设你知道什么是 XML 以及如何编写它。如果你知道一些 HTML,你已经熟悉的基础。XML 并不是真正的特定语言(比如 HTML);它更像是定义一类语言的一套规则。基本上,您仍然可以像在 HTML 中一样编写标记,但是在 XML 中,您可以自己发明标记名。这种特定的标记名集合及其结构关系可以在文档类型定义或 XML 模式中描述——我在这里不讨论这些。
关于什么是 XML 的简明描述,请参见万维网联盟(W3C)的“XML 十要点”( https://www.w3.org/XML/1999/XML-in-10-points-19990327
)。更详细的教程可以在 W3Schools 网站上找到( http://www.w3schools.com/xml
)。关于 SAX 的更多信息,请参见 SAX 官方网站( http://www.saxproject.org
)。
有什么问题?
在这个项目中,您要解决的一般问题是解析(读取和处理)XML 文件。因为您可以使用 XML 来表示几乎任何东西,并且在解析数据时可以对数据做任何想做的事情,所以应用是无限的(正如本章的标题所示)。本章要解决的具体问题是从一个 XML 文件生成一个完整的网站,该文件包含网站的结构和每个页面的基本内容。
在您着手这个项目之前,我建议您花一些时间阅读一些关于 XML 的知识,并了解它的应用。这可能会让你更好地理解什么时候它可能是一种有用的文件格式,什么时候它可能是多余的。(毕竟,当你只需要纯文本文件的时候,它们就可以了。)
Anything, You Say?
您可能会怀疑用 XML 到底能表示什么。好吧,让我给你举几个它的用法的例子:
- 为普通文档处理标记文本—例如,以 XHTML (
http://www.w3.org/TR/xhtml1
)或 DocBook XML (http://www.docbook.org
)的形式 - 来代表音乐(
http://musicxml.org
) - 来代表人类的心情、情绪和性格特征(
http://xml.coverpages.org/
humanML.html
) - 描述任何物理对象(
http://xml.coverpages.org/pml-ons.html
)
- 通过网络调用 Python 方法(使用 XML-RPC,在第二十七章中演示)
XML 的现有应用示例可以在 XML 封面( http://xml.coverpages.org/xml.html#applications
)中找到。
让我们来定义项目的具体目标。
- 整个网站应该用一个 XML 文件来描述,这个文件应该包含关于各个网页和目录的信息。
- 该计划应根据需要创建目录和网页。
- 应该很容易改变整个网站的总体设计,并用新设计重新生成所有页面。
这最后一点可能足以让这一切变得值得,但还有其他好处。通过将所有内容放在一个 XML 文件中,您可以轻松地编写其他程序,使用相同的 XML 处理技术提取各种信息,如目录、自定义搜索引擎的索引等。即使您的网站不使用它,您也可以用它来创建基于 HTML 的幻灯片(或者,通过使用上一章讨论的 ReportLab,您甚至可以创建 PDF 幻灯片)。
有用的工具
Python 有一些内置的 XML 支持,但是如果您使用的是旧版本,您可能需要自己安装一些额外的支持。在这个项目中,您需要一个正常工作的 SAX 解析器。要查看您是否有可用的 SAX 解析器,请尝试执行以下命令:
>>> from xml.sax import make_parser
>>> parser = make_parser()
当您这样做时,很可能不会引发任何异常。在这种情况下,您已经准备好了,可以继续“准备”部分。
Tip
现在有很多针对 Python 的 XML 工具。“标准”PyXML 框架的一个非常有趣的替代方案是 Fredrik Lundh 的 ElementTree(和 C 实现 cElementTree),它也包含在 Python 标准库的最新版本中,在包xml.etree
中。如果你有更老的 Python 版本,可以从 http://effbot.org/zone
获得 ElementTree。它非常强大且易于使用,如果您真的想在 Python 中使用 XML 的话,很值得一看。
如果您确实得到了一个异常,您必须安装 PyXMLweb 搜索应该会为您指出正确的方向(除非您的 Python 很古老,尽管它应该自带 XML 支持)。
准备
在编写处理 XML 文件的程序之前,必须设计 XML 格式。你需要什么标签,它们应该有什么属性,哪些标签应该放在哪里?为了找到答案,让我们首先考虑你希望你的格式描述什么。
主要的概念是网站、目录、页面、名称、标题和内容。
- 您不会存储关于网站本身的任何信息,所以网站只是包含所有文件和目录的顶级元素。
- 目录主要是文件和其他目录的容器。
- 页面是单个网页。
- 目录和网页都需要名字。这些将被用作目录名和文件名,因为它们将出现在文件系统和相应的 URL 中。
- 每个网页都应该有一个标题(与其文件名不同)。
- 每个网页也会有一些内容。我们将使用普通的 XHTML 来表示这里的内容。这样,我们可以简单地将它传递到最终的网页,让浏览器来解释它。
简而言之,您的文档将由一个单独的website
元素组成,包含几个directory
和page
元素,每个目录元素可选地包含更多的页面和目录。directory
和page
元素将有一个名为name
的属性,其中将包含它们的名称。另外,page
标签有一个title
属性。元素包含 XHTML 代码(属于 XHTML body
标签中的类型)。清单 22-1 中显示了一个样本文件。
<website>
<page name="index" title="Home Page">
<h1>Welcome to My Home Page</h1>
<p>Hi, there. My name is Mr. Gumby, and this is my home page.
Here are some of my interests:</p>
<ul>
<li><a href="interests/shouting.html">Shouting</a></li>
<li><a href="interests/sleeping.html">Sleeping</a></li>
<li><a href="interests/eating.html">Eating</a></li>
</ul>
</page>
<directory name="interests">
<page name="shouting" title="Shouting">
<h1>Mr. Gumby's Shouting Page</h1>
<p>...</p>
</page>
<page name="sleeping" title="Sleeping">
<h1>Mr. Gumby's Sleeping Page</h1>
<p>...</p>
</page>
<page name="eating" title="Eating">
<h1>Mr. Gumby's Eating Page</h1>
<p>...</p>
</page>
</directory>
</website>
Listing 22-1.A Simple Web Site Represented
As an XML File (website.xml)
首次实施
此时,我们还没有看到 XML 解析是如何工作的。我们在这里使用的方法(称为 SAX)包括编写一组事件处理程序(就像在 GUI 编程中一样),然后让现有的 XML 解析器在读取 XML 文档时调用这些处理程序。
What about DOM?
Python(以及其他编程语言)中有两种处理 XML 的常用方法:SAX 和文档对象模型(DOM)。SAX 解析器通读 XML 文件,告诉您它看到了什么(文本、标记和属性),一次只存储文档的一小部分。这使得 SAX 简单、快速并且节省内存,这也是我在本章中选择使用它的原因。DOM 采用另一种方法:它构造一个数据结构(文档树),表示整个文档。这种方法速度较慢,需要更多的内存,但如果您想操作文档的结构,这种方法会很有用。
创建简单的内容处理程序
使用 SAX 进行解析时,有几种事件类型可用,但是让我们将自己限制为三种:元素的开始(开始标记的出现)、元素的结束(结束标记的出现)和纯文本(字符)。为了解析 XML 文件,让我们使用来自xml.sax
模块的parse
函数。这个函数负责读取文件和生成事件,但是在生成这些事件时,它需要调用一些事件处理程序。这些事件处理程序将作为内容处理程序对象的方法来实现。您将从xml.sax.handler
继承ContentHandler
类,因为它实现了所有必要的事件处理程序(作为无效的虚拟操作),并且您可以只覆盖您需要的那些。
让我们从一个最小的 XML 解析器开始(假设您的 XML 文件叫做website.xml
)。
from xml.sax.handler import ContentHandler
from xml.sax import parse
class TestHandler(ContentHandler): pass
parse('website.xml', TestHandler())
如果你执行这个程序,看起来什么也没发生,但是你也不会得到任何错误信息。在幕后,XML 文件被解析,默认的事件处理程序被调用,但是因为它们不做任何事情,所以您看不到任何输出。
让我们尝试一个简单的扩展。将以下方法添加到TestHandler
类中:
def startElement(self, name, attrs):
print(name, attrs.keys())
这将覆盖默认的startElement
事件处理程序。参数是相关的标记名及其属性(保存在一个类似字典的对象中)。如果您再次运行该程序(使用清单 22-1 中的website.xml
,您会看到以下输出:
website []
page [u'name', u'title']
h1 []
p []
ul []
li []
a [u'href']
li []
a [u'href']
li []
a [u'href']
directory [u'name']
page [u'name', u'title']
h1 []
p []
page [u'name', u'title']
h1 []
p []
page [u'name', u'title']
h1 []
p []
这是如何工作的应该很清楚了。除了startElement
,我们还将使用endElement
(它只接受一个标签名作为参数)和characters
(它接受一个字符串作为参数)。
下面是一个使用所有这三种方法构建网站文件标题列表的例子:
from xml.sax.handler import ContentHandler
from xml.sax import parse
class HeadlineHandler(ContentHandler):
in_headline = False
def __init__(self, headlines):
super().__init__()
self.headlines = headlines
self.data = []
def startElement(self, name, attrs):
if name == 'h1':
self.in_headline = True
def endElement(self, name):
if name == 'h1':
text = ''.join(self.data)
self.data = []
self.headlines.append(text)
self.in_headline = False
def characters(self, string):
if self.in_headline:
self.data.append(string)
headlines = []
parse('website.xml', HeadlineHandler(headlines))
print('The following <h1> elements were found:')
for h in headlines:
print(h)
注意,HeadlineHandler
跟踪它当前是否正在解析一对h1
标签中的文本。这是通过当startElement
找到一个h1
标签时将self.in_headline
设置为True
以及当endElement
找到一个h1
标签时将self.in_headline
设置为False
来实现的。当解析器找到一些文本时,会自动调用characters
方法。只要解析器在两个h1
标签之间(self.in_headline
是True
),那么characters
就会把字符串(可能只是标签之间文本的一部分)追加到self.data
中,也就是一个字符串列表。连接这些文本片段,将它们附加到self.headlines
(作为单个字符串),并将self.data
重置为空列表的任务也落到了endElement
身上。这种通用方法(使用布尔变量来指示您当前是否在给定标记类型的“内部”)在 SAX 编程中非常常见。
运行这个程序(同样,使用清单 22-1 中的website.xml
文件),您会得到以下输出:
The following <h1> elements were found:
Welcome to My Home Page
Mr. Gumby's Shouting Page
Mr. Gumby's Sleeping Page
Mr. Gumby's Eating Page
创建 HTML 页面
现在,您已经准备好制作原型了。现在,让我们忽略目录,专注于创建 HTML 页面。您需要创建一个稍加修饰的事件处理程序来完成以下任务:
- 在每个
page
元素的开头,用给定的名称打开一个新文件,并向其中写入一个合适的 HTML 标题,包括给定的标题 - 在每个
page
元素的末尾,向文件写入一个合适的 HTML 页脚,并关闭它 - 在
page
元素内部,遍历所有标签和字符而不修改它们(按原样写入文件) - 当不在
page
元素中时,忽略所有标签(如website
和directory
大部分都很简单(至少如果你知道一点 HTML 文档是如何构造的)。然而,有两个问题可能并不十分明显。
- 您不能简单地“传递”标签(将它们直接写到您正在构建的 HTML 文件中),因为您只能获得它们的名称(可能还有一些属性)。您必须自己重建标签(用尖括号等等)。
- SAX 本身无法知道您当前是否在一个
page
元素的“内部”。
你必须自己跟踪这类事情(就像你在HeadlineHandler
例子中所做的那样)。对于这个项目,您只对是否传递标签和字符感兴趣,所以您将使用一个名为passthrough
的布尔变量,它将在您进入和离开页面时更新。
简单程序的代码见清单 22-2 。
from xml.sax.handler import ContentHandler
from xml.sax import parse
class PageMaker(ContentHandler):
passthrough = False
def startElement(self, name, attrs):
if name == 'page':
self.passthrough = True
self.out = open(attrs['name'] + '.html', 'w')
self.out.write('<html><head>\n')
self.out.write('<title>{}</title>\n'.format(attrs['title']))
self.out.write('</head><body>\n')
elif self.passthrough:
self.out.write('<' + name)
for key, val in attrs.items():
self.out.write(' {}="{}"'.format(key, val))
self.out.write('>')
def endElement(self, name):
if name == 'page':
self.passthrough = False
self.out.write('\n</body></html>\n')
self.out.close()
elif self.passthrough:
self.out.write('</{}>'.format(name))
def characters(self, chars):
if self.passthrough: self.out.write(chars)
parse('website.xml', PageMaker())
Listing 22-2.A Simple Page Maker Script
(pagemaker.py)
您应该在您希望文件出现的目录中执行此操作。请注意,即使两个页面位于两个不同的目录元素中,它们最终也会位于同一个真实目录中。(这将在我们的第二次实现中解决。)
同样,使用清单 22-1 中的文件website.xml
,可以得到四个 HTML 文件。名为index.html
的文件包含以下内容:
<html><head>
<title>Home Page</title>
</head><body>
<h1>Welcome to My Home Page</h1>
<p>Hi, there. My name is Mr. Gumby, and this is my home page. Here are some of my interests:</p>
<ul>
<li><a href="interests/shouting.html">Shouting</a></li>
<li><a href="interests/sleeping.html">Sleeping</a></li>
<li><a href="interests/eating.html">Eating</a></li>
</ul>
</body></html>
图 22-1 显示了该页面在浏览器中的外观。
图 22-1。
A generated web page
查看代码,两个主要的弱点应该是显而易见的。
- 它使用
if
语句来处理各种事件类型。如果您需要处理许多这样的事件类型,您的if
语句将变得很大并且不可读。 - HTML 代码是硬连线的。应该很容易更换。
这两个弱点将在第二个实现中解决。
第二次实施
因为 SAX 机制是如此低级和基本,所以您可能会发现编写一个混合类来处理一些管理细节非常有用,比如收集字符数据、管理布尔状态变量(比如passthrough
)或者将事件分派给自己的定制事件处理程序。在这个项目中,状态和数据处理非常简单,所以让我们把重点放在处理程序调度上。
调度员混合班
不需要在标准的通用事件处理程序(如startElement
)中编写大量的if
语句,最好只编写自己的特定语句(如startPage
)并自动调用它们。您可以在一个 mix-in 类中实现该功能,然后将 mix-in 和ContentHandler
一起子类化。
Note
正如第七章中提到的,mix-in 是一个功能有限的类,它意味着与其他一些更重要的类一起被子类化。
您希望您的程序具有以下功能:
- 当用一个名字如
'foo'
调用startElement
时,它应该试图找到一个名为startFoo
的事件处理程序,并用给定的属性调用它。 - 同样,如果用
'foo'
调用endElement
,它应该尝试调用endFoo
。 - 如果在这些方法中没有找到给定的处理程序,将调用一个名为
defaultStart
(或defaultEnd
)的方法(如果存在的话)。如果默认的处理程序也不存在,就不应该做任何事情。
此外,应该注意参数。定制处理程序(例如,startFoo
)不需要标签名作为参数,而定制默认处理程序(例如,defaultStart
)需要。此外,只有启动处理程序需要这些属性。
迷茫?让我们从编写类中最简单的部分开始。
class Dispatcher:
# ...
def startElement(self, name, attrs):
self.dispatch('start', name, attrs)
def endElement(self, name):
self.dispatch('end', name)
这里实现了基本的事件处理程序,它们简单地调用一个名为dispatch
的方法,该方法负责寻找合适的处理程序,构造参数元组,然后用这些参数调用处理程序。下面是dispatch
方法的代码:
def dispatch(self, prefix, name, attrs=None):
mname = prefix + name.capitalize()
dname = 'default' + prefix.capitalize()
method = getattr(self, mname, None)
if callable(method): args = ()
else:
method = getattr(self, dname, None)
args = name,
if prefix == 'start': args += attrs,
if callable(method): method(*args)
下面是发生的情况:
- 根据一个前缀(或者是
'start'
或者是'end'
)和一个标签名(例如'page'
,构造处理程序的方法名(例如'startPage'
)。 - 使用相同的前缀,构造默认处理程序的名称(例如,
'defaultStart'
)。 - 尝试用
getattr
获取处理程序,使用None
作为缺省值。 - 如果结果是可调用的,则为
args
分配一个空元组。 - 否则,尝试使用
getattr
获取默认处理程序,再次使用None
作为默认值。另外,将args
设置为只包含标签名的元组(因为默认处理程序需要它)。 - 如果您正在处理一个开始处理程序,将属性添加到参数元组(
args
)。 - 如果您的处理程序是可调用的(也就是说,它或者是可行的特定处理程序,或者是可行的默认处理程序),请使用正确的参数调用它。
明白了吗?这基本上意味着您现在可以像这样编写内容处理程序:
class TestHandler(Dispatcher, ContentHandler):
def startPage(self, attrs):
print('Beginning page', attrs['name'])
def endPage(self):
print('Ending page')
因为 dispatcher mix-in 负责大部分的管道工作,所以内容处理程序相当简单,可读性很强。(当然,我们稍后会添加更多功能。)
分解出页眉、页脚和默认处理
这一节比上一节容易得多。我们将创建单独的方法来编写页眉和页脚,而不是直接在事件处理程序中调用self.out.write
。这样,我们可以很容易地通过子类化事件处理程序来覆盖这些方法。让我们让默认的页眉和页脚变得非常简单。
def writeHeader(self, title):
self.out.write("<html>\n <head>\n <title>")
self.out.write(title)
self.out.write("</title>\n </head>\n <body>\n")
def writeFooter(self):
self.out.write("\n </body>\n</html>\n")
XHTML 内容的处理也与原始处理程序联系得过于紧密。XHTML 现在将由defaultStart
和defaultEnd
来处理。
def defaultStart(self, name, attrs):
if self.passthrough:
self.out.write('<' + name)
for key, val in attrs.items():
self.out.write(' {}="{}"'.format(key, val))
self.out.write('>')
def defaultEnd(self, name):
if self.passthrough:
self.out.write('</{}>'.format(name))
这和以前一样,只是我把代码移到了不同的方法中(这通常是件好事)。现在,到了拼图的最后一块。
支持目录
要创建必要的目录,您需要函数os.makedirs
,它将所有必要的目录放在给定的路径中。例如,os.makedirs('foo/bar/baz')
在当前目录中创建目录foo
,然后在foo
中创建bar
,最后在bar
中创建baz
。如果foo
已经存在,则只创建bar
和baz
,同样,如果bar
也存在,则只创建baz
。但是,如果baz
也存在,通常会引发异常。为了避免这种情况,我们提供了关键字参数exist_ok=True
。另一个有用的函数是os.path.join
,它用正确的分隔符连接几个路径(例如,UNIX 中的/
等等)。
在处理过程中,始终将当前目录路径保存为目录名列表,由变量directory
引用。当您输入一个目录时,附加它的名称;你离开的时候,把名字去掉。假设directory
设置正确,您可以定义一个函数来确保当前目录存在。
def ensureDirectory(self):
path = os.path.join(*self.directory)
os.makedirs(path, exist_ok=True)
请注意,在将目录列表提供给os.path.join
时,我是如何在目录列表上使用参数拼接的(带有星号运算符,*
)。
我们网站的基目录(例如,public_html
)可以作为构造函数的一个参数,它看起来像这样:
def __init__(self, directory):
self.directory = [directory]
self.ensureDirectory()
事件处理程序
最后,我们来到了事件处理程序。您需要四个:两个用于处理目录,两个用于页面。目录处理程序简单地使用了directory
列表和ensureDirectory
方法。
def startDirectory(self, attrs):
self.directory.append(attrs['name'])
self.ensureDirectory()
def endDirectory(self):
self.directory.pop()
页面处理程序使用writeHeader
和writeFooter
方法。此外,他们还设置了passthrough
变量(通过 XHTML),而且——也许是最重要的——他们打开和关闭与页面相关的文件:
def startPage(self, attrs):
filename = os.path.join(*self.directory + [attrs['name'] + '.html'])
self.out = open(filename, 'w')
self.writeHeader(attrs['title'])
self.passthrough = True
def endPage(self):
self.passthrough = False
self.writeFooter()
self.out.close()
startPage
的第一行可能看起来有点吓人,但它与ensureDirectory
的第一行或多或少是一样的,除了您添加了文件名(并给它加了一个.html
后缀)。
该程序的完整源代码如清单 22-3 所示。
from xml.sax.handler import ContentHandler
from xml.sax import parse
import os
class Dispatcher:
def dispatch(self, prefix, name, attrs=None):
mname = prefix + name.capitalize()
dname = 'default' + prefix.capitalize()
method = getattr(self, mname, None)
if callable(method): args = ()
else:
method = getattr(self, dname, None)
args = name,
if prefix == 'start': args += attrs,
if callable(method): method(*args)
def startElement(self, name, attrs):
self.dispatch('start', name, attrs)
def endElement(self, name):
self.dispatch('end', name)
class WebsiteConstructor(Dispatcher, ContentHandler):
passthrough = False
def __init__(self, directory):
self.directory = [directory]
self.ensureDirectory()
def ensureDirectory(self):
path = os.path.join(*self.directory)
os.makedirs(path, exist_ok=True)
def characters(self, chars):
if self.passthrough: self.out.write(chars)
def defaultStart(self, name, attrs):
if self.passthrough:
self.out.write('<' + name)
for key, val in attrs.items():
self.out.write(' {}="{}"'.format(key, val))
self.out.write('>')
def defaultEnd(self, name):
if self.passthrough:
self.out.write('</{}>'.format(name))
def startDirectory(self, attrs):
self.directory.append(attrs['name'])
self.ensureDirectory()
def endDirectory(self):
self.directory.pop()
def startPage(self, attrs):
filename = os.path.join(*self.directory + [attrs['name'] + '.html'])
self.out = open(filename, 'w')
self.writeHeader(attrs['title'])
self.passthrough = True
def endPage(self):
self.passthrough = False
self.writeFooter()
self.out.close()
def writeHeader(self, title):
self.out.write('<html>\n <head>\n <title>')
self.out.write(title)
self.out.write('</title>\n </head>\n <body>\n')
def writeFooter(self):
self.out.write('\n </body>\n</html>\n')
parse('website.xml', WebsiteConstructor('public_html'))
Listing 22-3.The Web Site Constructor (website.py)
清单 22-3 生成以下文件和目录:
public_html/
public_html/index.
html
public_html/interests/
public_html/interests/shouting.html
public_html/interests/sleeping.html
public_html/interests/eating.html
进一步探索
现在你有了基本程序。你能用它做什么?以下是一些建议:
- 创建一个新的
ContentHandler
,用于为网站生成目录或菜单(带链接)。 - 在网页上添加导航工具,告诉用户他们在哪里(在哪个目录下)。
- 创建一个
WebsiteConstructor
的子类,覆盖writeHeader
和writeFooter
,以提供定制的设计。 - 创建另一个从 XML 文件构建单个 web 页面的
ContentHandler
。 - 创建一个以某种方式总结你的网站的
ContentHandler
,例如,在 RSS 中。 - 查看其他转换 XML 的工具,尤其是 XML 转换(XSLT)。
- 使用诸如 ReportLab 的 Platypus (
http://www.reportlab.org
)之类的工具,基于 XML 文件创建一个或多个 PDF 文档。 - 使通过网络界面编辑 XML 文件成为可能(参见第二十五章)。
什么现在?
在涉足 XML 解析领域之后,让我们再做一些网络编程。在下一章中,我们将创建一个程序,它可以从各种网络资源中收集新闻条目,并为您生成定制的新闻报告。
二十三、项目 4:在新闻中
互联网上充斥着各种形式的新闻来源,包括报纸、视频频道、博客和播客等等。其中一些还提供服务,如 RSS 或 Atom 提要,让您使用相对简单的代码检索最新的新闻,而不必解析它们的网页。在这个项目中,我们将探索一种先于网络的机制:网络新闻传输协议(NNTP)。我们将从一个没有任何抽象形式(没有函数,没有类)的简单原型到一个添加了一些重要抽象的通用系统。我们将使用nntplib
库,它允许您与 NNTP 服务器交互,但是添加其他协议和机制应该很简单。
NNTP 是一种标准的网络协议,用于管理在所谓的新闻组讨论组上发布的消息。NNTP 服务器形成了一个全球网络,集中管理这些新闻组,通过 NNTP 客户端(也称为新闻阅读器),你可以张贴和阅读消息。NNTP 服务器的主要网络叫做新闻组,建立于 1980 年(尽管 NNTP 协议直到 1985 年才被使用)。与当前的网络趋势相比,这是相当“老派”的,但大多数互联网(在某种程度上)是基于这样的老派技术,和它可能不会伤害周围玩一些低级的东西。此外,你也可以用自己的新闻采集模块来代替本章中的 NNTP 内容(也许使用脸书或 Twitter 等社交网站的 web API)。
有什么问题?
您在本项目中编写的程序将是一个信息收集代理,一个可以收集信息(更具体地说,新闻)并为您编写报告的程序。考虑到您已经遇到的网络功能,这似乎不是很困难——事实上也不是。但是在这个项目中,你超越了简单的“用urllib
下载文件”的方法。你使用了另一个比urllib
更难使用的网络库,即nntplib
。此外,您还可以重构程序,以允许多种类型的新闻源和各种类型的目的地,在前端和后端之间进行明确的分离,主引擎位于中间。
最终计划的主要目标如下:
- 这个程序应该能够从许多不同的来源收集新闻。
- 添加新的新闻来源(甚至是新种类的来源)应该很容易。
- 这个程序应该能够以多种不同的格式将编辑好的新闻报道发送到不同的目的地。
- 添加新的目的地(甚至新类型的目的地)应该很容易。
有用的工具
对于这个项目,你不需要安装单独的软件。然而,您确实需要一些标准的库模块,包括一个您以前没有见过的模块,nntplib
,它处理 NNTP 服务器。我们不解释该模块的所有细节,而是通过一些原型来检查它。
准备
为了能够使用nntplib
,你需要能够访问 NNTP 服务器。如果您不确定是否需要,您可以向您的 ISP 或系统管理员询问详细信息。在本章的代码示例中,我使用了新闻组comp.lang.python.announce
,所以你应该确保你的新闻(NNTP)服务器有这个组,或者你应该找到你想使用的其他组。如果您无法访问 NNTP 服务器,任何人都可以使用几个开放的服务器。在网上快速搜索“免费的 nntp 服务器”会给你一些可供选择的服务器。(nntplib
官方文档中的代码示例使用news.gmane.org
。)假设你的新闻服务器是news.foo.bar
(这不是真实的服务器名,不会起作用),你可以这样测试你的 NNTP 服务器:
>>> from nntplib import NNTP
>>> server = NNTP('news.foo.bar')
>>> server.group('comp.lang.python.announce')[0]
Note
要连接到某些服务器,您可能需要提供额外的身份验证参数。有关 NNTP 构造函数可选参数的详细信息,请参考 Python 库参考( https://docs.python.org/library/nntplib.html
)。
最后一行的结果应该是以'211'
(基本意思是服务器有你要的组)或者'411'
(意思是服务器没有组)开头的字符串。它可能看起来像这样:
'211 51 1876 1926 comp.lang.python.announce'
如果返回的字符串以'411'
开头,您应该使用新闻阅读器来查找您可能想要使用的另一个组。(您也可能会得到一个带有等效错误消息的异常。)如果出现异常,可能是您弄错了服务器名。另一种可能是在创建服务器对象和调用group
方法之间“超时”——服务器可能只允许您在很短的时间内(比如 10 秒)保持连接。如果您很难快速输入,只需将代码放入脚本并执行它(添加一个print
)或将服务器对象创建和方法调用放在同一行(用分号分隔)。
首次实施
本着原型的精神,让我们直接解决这个问题。您要做的第一件事是从 NNTP 服务器上的新闻组下载最新消息。为了简单起见,只需将结果打印到标准输出(用print
)。在查看实现的细节之前,您可能想要浏览本节后面的清单 23-1 中的源代码,甚至可能执行程序来看看它是如何工作的。程序逻辑并不复杂——挑战主要在于使用nntplib
。我们将使用NNTP
类的一个对象,正如您在上一节中看到的,这个类是用 NNTP 服务器的名称实例化的。您需要在这个实例上调用三个方法。
group
,它选择一个给定的新闻组作为当前新闻组,并返回关于它的一些信息,包括最后一条消息的编号over
,为您提供由编号指定的一组消息的概述信息body
,返回给定消息的正文
使用与前面相同的虚构服务器名,我们可以进行如下设置:
servername = 'news.foo.bar'
group = 'comp.lang.python.announce'
server = NNTP(servername)
howmany = 10
howmany
变量表示我们想要检索多少篇文章。然后我们可以选择我们的组。
resp, count, first, last, name = server.group(group)
返回值是一般的服务器响应、组中消息的估计数量、第一个和最后一个消息编号以及组的名称。我们主要对last
感兴趣,我们将使用它来构建我们感兴趣的文章编号的区间,从start = last - howmany + 1
开始,以last
结束。我们将这一对数字提供给over
方法,该方法为我们提供了一系列消息的(id, overview)
对。我们从概述中提取主题,并使用 ID 从服务器获取消息正文。
消息正文的行以字节形式返回。如果我们使用默认的 UTF-8 来解码它们,如果我们猜错了,我们可能会得到一些非法的字节序列。理想情况下,我们应该提取编码信息,但为了简单起见,我们只使用 Latin-1 编码,它适用于普通 ASCII,不会抱怨非 ASCII 字节。打印完所有文章,我们调用server.quit()
,就这样。在像bash
这样的 UNIX shell 中,您可以像这样运行这个程序:
$ python newsagent1.py | less
使用less
有助于一次阅读一篇文章。如果你没有这样的分页程序可用,你可以重写程序的print
部分,将结果文本存储在一个文件中,这也是你在第二个实现中要做的(参见第十一章了解更多关于文件处理的信息)。清单 23-1 显示了简单的新闻收集代理的源代码。
from nntplib import NNTP
servername = 'news.foo.bar'
group = 'comp.lang.python.announce'
server = NNTP(servername)
howmany = 10
resp, count, first, last, name = server.group(group)
start = last - howmany + 1
resp, overviews = server.over((start, last))
for id, over in overviews:
subject = over['subject']
resp, info = server.body(id)
print(subject)
print('-' * len(subject))
for line in info.lines:
print(line.decode('latin1'))
print()
server.quit()
Listing 23-1.A Simple News-Gathering Agent
(newsagent1.py)
第二次实施
第一个实现是可行的,但是相当不灵活,因为它只允许您从新闻组讨论组中检索新闻。在第二个实现中,您可以通过稍微重构代码来解决这个问题。您可以通过创建一些类和方法来表示代码的各个部分,从而添加结构和抽象。一旦你这样做了,一些部分可能会被其他类替换,这比你替换原始程序中的部分代码要容易得多。
同样,在深入了解第二个实现的细节之前,您可能想浏览(或者执行)本章后面的清单 23-2 中的代码。
Note
在清单 23-2 中的代码运行之前,您需要将clpa_server
变量设置为可用的 NNTP 服务器。
那么,需要上什么课呢?我们先简单回顾一下问题描述中的重要名词,如第七章所建议的:信息、代理、新闻、报道、网络、新闻来源、目的地、前端、后端、主引擎。这个名词列表暗示了以下主要类别(或类别种类):NewsAgent
、NewsItem
、Source
和Destination
。
各种来源将构成前端,目的地将构成后端,新闻代理位于中间。
这其中最简单的就是NewsItem
。它只表示一段数据,由标题和正文(一段简短的文本)组成,可以按如下方式实现:
class NewsItem:
def __init__(self, title, body):
self.title = title
self.body = body
要确切地了解新闻源和新闻目的地需要什么,从编写代理本身开始可能是一个好主意。代理必须维护两个列表:一个是源列表,一个是目的列表。可以通过方法addSource
和addDestination
添加源和目的地。
class NewsAgent:
def __init__(self):
self.sources = []
self.destinations = []
def addSource(self, source):
self.sources.append(source)
def addDestination(self, dest):
self.destinations.append(dest)
现在唯一缺少的是一种将新闻从源分发到目的地的方法。在分发过程中,每个目的地都必须有一个返回其所有新闻项的方法,每个源都需要一个接收正在分发的所有新闻项的方法。我们称这些方法为getItems
和receiveItems
。为了灵活起见,让我们只要求getItems
返回一个任意的NewsItems
迭代器。然而,为了使目的地更容易实现,让我们假设receiveItems
可以用一个序列参数调用(例如,可以迭代多次,在列出新闻条目之前制作一个目录)。在这被决定之后,NewsAgent
的distribute
方法简单地变成如下:
def distribute(self):
items = []
for source in self.sources:
items.extend(source.getItems())
for dest in self.destinations:
dest.receiveItems(items)
这将遍历所有来源,构建一个新闻条目列表。然后,它遍历所有目的地,并为每个目的地提供完整的新闻条目列表。
现在,你只需要几个来源和目的地。要开始测试,您可以简单地创建一个目的地,就像第一个原型中的打印一样。
class PlainDestination:
def receiveItems(self, items):
for item in items:
print(item.title)
print('-' * len(item.title))
print(item.body)
格式是相同的;不同之处在于您封装了格式。它现在是几个可选目的地之一,而不是程序的硬编码部分。在本章后面的清单 23-2 中可以看到一个稍微复杂一点的目的地(HTMLDestination
,它产生 HTML)。它基于PlainDestination
的方法,增加了一些特性。
- 它产生的文本是 HTML。
- 它将文本写入特定文件,而不是标准输出。
- 除了主项目列表之外,它还创建了一个目录。
就这样,真的。目录是使用链接到网页各部分的超链接创建的。我们将通过使用形式为<a href="#nn">...</a>
(其中nn
是某个数字)的链接来实现这一点,这将导致带有封闭锚标记<a name="nn">...</a>
的标题(其中nn
应该与目录中的数字相同)。目录和主要新闻条目列表构建在两个不同的for
循环中。你可以在图 23-1 中看到一个样本结果(使用即将到来的NNTPSource
)。
图 23-1。
An automatically generated news page
在考虑设计时,我考虑使用一个泛型超类来表示新闻源,一个表示新闻目的地。事实证明,源和目的地并不真正共享任何行为,所以使用公共超类没有意义。只要他们正确地实现了必要的方法(getItems
和receiveItems
),NewsAgent
就会很高兴。(这是一个使用协议的例子,如第九章所述,而不是要求一个特定的、公共的超类。)
当创建一个NNTPSource
时,大部分代码可以从原始原型中截取。正如您将在清单 23-2 中看到的,与原始版本的主要区别如下:
- 代码被封装在
getItems
方法中。servername
和group
变量现在是构造函数的参数。另外,howmany
变量已经变成了这个类的构造函数参数。 - 我添加了一个对
decode_header
的调用,它处理标题字段(如 subject)中使用的一些特殊编码。 - 不是直接打印每个新闻条目,而是生成一个
NewsItem
对象(使getItems
成为一个生成器)。
为了展示设计的灵活性,让我们添加另一个新闻源—一个可以从网页中提取新闻条目的新闻源(使用正则表达式;更多信息见第十章。SimpleWebSource
(参见清单 23-2 )将一个 URL 和两个正则表达式(一个表示标题,一个表示主体)作为其构造函数参数。在getItems
中,它使用正则表达式方法findall
来查找所有出现的内容(标题和正文)并使用zip
来组合这些内容。然后,它遍历(title, body)
对列表,为每个对生成一个NewsItem
。如您所见,添加新类型的源(或目的地,就此而言)并不困难。
为了让代码发挥作用,让我们实例化一个代理、一些源和一些目的地。在函数runDefaultSetup
(模块作为程序运行时调用)中,实例化了几个这样的对象。
- 路透社网站的一个
SimpleWebSource
,它使用两个简单的正则表达式来提取它需要的信息
Note
Reuters 页面上的 HTML 布局可能会改变,在这种情况下,您需要重写正则表达式。当然,这也适用于你使用其他页面的情况。只需查看 HTML 源代码,并尝试找到适用的模式。
- 一个
NNTPSource
代表comp.lang.python
,其中howmany
设置为 10,所以它的工作方式就像第一个原型一样 - 一个
PlainDestination
,打印所有收集的新闻 - 一个
HTMLDestination
,它生成一个名为news.html
的新闻页面
当所有这些对象都被创建并添加到NewsAgent
时,就调用distribute
方法。您可以像这样运行程序:
$ python newsagent2.py
产生的news.html
页面如图 23-2 所示。第二个实现的完整源代码可以在清单 23-2 中找到。
图 23-2。
A news page with more than one source
from nntplib import NNTP, decode_header
from urllib.request import urlopen
import textwrap
import re
class NewsAgent:
"""
An object that can distribute news items from news sources to news
destinations.
"""
def __init__(self):
self.sources = []
self.destinations = []
def add_source(self, source):
self.sources.append(source)
def addDestination(self, dest):
self.destinations.append(dest)
def distribute(self):
"""
Retrieve all news items from all sources, and Distribute them to all
destinations.
"""
items = []
for source in self.sources:
items.extend(source.get_items())
for dest in self.destinations:
dest.receive_items(items)
class NewsItem:
"""
A simple news item consisting of a title and body text.
"""
def __init__(self, title, body):
self.title = title
self.body = body
class NNTPSource:
"""
A news source that retrieves news items from an NNTP group.
"""
def __init__(self, servername, group, howmany):
self.servername = servername
self.group = group
self.howmany = howmany
def get_items(self):
server = NNTP(self.servername)
resp, count, first, last, name = server.group(self.group)
start = last - self.howmany + 1
resp, overviews = server.over((start, last))
for id, over in overviews:
title = decode_header(over['subject'])
resp, info = server.body(id)
body = '\n'.join(line.decode('latin')
for line in info.lines) + '\n\n'
yield NewsItem(title, body)
server.quit()
class SimpleWebSource:
"""
A news source that extracts news items from a web page using regular
expressions.
"""
def __init__(self, url, title_pattern, body_pattern, encoding='utf8'):
self.url = url
self.title_pattern = re.compile(title_pattern)
self.body_pattern = re.compile(body_pattern)
self.encoding = encoding
def get_items(self):
text = urlopen(self.url).read().decode(self.encoding)
titles = self.title_pattern.findall(text)
bodies = self.body_pattern.findall(text)
for title, body in zip(titles, bodies):
yield NewsItem(title, textwrap.fill(body) + '\n')
class PlainDestination:
"""
A news destination that formats all its news items as plain text.
"""
def receive_items(self, items):
for item in items:
print(item.title)
print('-' * len(item.title))
print(item.body)
class HTMLDestination:
"""
A news destination that formats all its news items as HTML.
"""
def __init__(self, filename):
self.filename = filename
def receive_items(self, items):
out = open(self.filename, 'w')
print("""
<html>
<head>
<title>Today's News</title>
</head>
<body>
<h1>Today's News</h1>
""", file=out)
print('<ul>', file=out)
id = 0
for item in items:
id += 1
print(' <li><a href="#{}">{}</a></li>'
.format(id, item.title), file=out)
print('</ul>', file=out)
id = 0
for item in items:
id += 1
print('<h2><a name="{}">{}</a></h2>'
.format(id, item.title), file=out)
print('<pre>{}</pre>'.format(item.body), file=out)
print("""
</body>
</html>
""", file=out)
def runDefaultSetup():
"""
A default setup of sources and destination. Modify to taste.
"""
agent = NewsAgent()
# A SimpleWebSource that retrieves news from Reuters:
reuters_url = 'http://www.reuters.com/news/world'
reuters_title = r'<h2><a href="[^"]*"\s*>(.*?)</a>'
reuters_body = r'</h2><p>(.*?)</p>'
reuters = SimpleWebSource(reuters_url, reuters_title, reuters_body)
agent.add_source(reuters)
# An NNTPSource that retrieves news from comp.lang.python.announce:
clpa_server = 'news.foo.bar' # Insert real server name
clpa_server = 'news.ntnu.no'
clpa_group = 'comp.lang.python.announce'
clpa_howmany = 10
clpa = NNTPSource(clpa_server, clpa_group, clpa_howmany)
agent.add_source(clpa)
# Add plain-text destination and an HTML destination:
agent.addDestination(PlainDestination())
agent.addDestination(HTMLDestination('news.html'))
# Distribute the news items:
agent.distribute()
if __name__ == '__main__': runDefaultSetup()
Listing 23-2.A More Flexible News-Gathering Agent (newsagent2.py
)
进一步探索
由于其可扩展的本质,这个项目需要进一步的探索。以下是一些想法:
- 使用第十五章中讨论的屏幕抓取技术,创建一个更加雄心勃勃的
WebSource
。 - 创建一个解析 RSS 的
RSSSource
,这也在第十五章中简要讨论过。 - 改进
HTMLDestination
的布局。 - 创建一个页面监视器,如果某个给定的 web 页面在您上次检查后发生了变化,它将为您提供一条新闻。(只需在发生变化时下载一份副本,然后进行比较。看看比较文件的标准库模块
filecmp
。) - 创建新闻脚本的 CGI 版本(参见第十五章)。
- 创建一个
EmailDestination
,它会向您发送一封包含新闻条目的电子邮件。(见标准库模块smtplib
发送邮件。) - 添加命令行开关来决定您需要的新闻格式。(参见标准库模块
argparse
了解一些技术。) - 给目的地提供关于新闻来源的信息,以允许更好的布局。
- 尝试对你的新闻条目进行分类(也许可以通过搜索关键词)。
- 创建一个
XMLDestination
,它产生适用于项目 3 中站点构建者的 XML 文件(第二十二章)。瞧,你有一个新闻网站。
什么现在?
我们已经做了大量的文件创建和文件处理(包括下载所需的文件),尽管这对很多事情来说非常有用,但它不是非常具有交互性。在下一个项目中,我们将创建一个聊天服务器,您可以在这里与您的朋友在线聊天。您甚至可以扩展它来创建您自己的虚拟(文本)环境。
Footnotes 1
例如,你知道在 http://groups.google.com
的讨论组,如sci.math``rec.arts.sf.written
其实是幕后的新闻组小组吗?
二十四、项目 5:虚拟茶会
在这个项目中,我们将做一些严肃的网络编程。我们将编写一个聊天服务器——一个程序,让几个人通过互联网连接起来,彼此实时聊天。在 Python 中有很多方法可以创建这样的野兽。一个简单而自然的方法可能是使用 Twisted 框架(在第十四章中讨论过),例如,LineReceiver
类占据中心位置。在这一章中,我将坚持使用标准库的异步网络模块。
值得注意的是,在撰写本文时,Python 似乎正处于这方面的空白期。虽然asyncore
和asynchat
模块的文档带有一个注释,说明它们只是为了向后兼容而被包含,并且未来的开发应该使用asyncio
模块,但是asyncio
的文档说明它只是在临时的基础上被包含,并且将来可能会被删除。我会走更保守的路线,用asyncore
和asynchat
。如果你愿意,你可以尝试一些在第十四章中讨论的替代方法(比如分叉或者线程),或者甚至使用asyncio
重写项目。
有什么问题?
我们准备写一个相对底层的服务器,用于在线聊天。虽然这种功能可以通过大量的社交媒体和消息服务获得,但编写自己的代码对于了解更多的网络编程是有用的。假设我们有以下需求:
- 服务器应该能够接收来自不同用户的多个连接。
- 它应该让用户并行操作。
- 它应该能够解释命令,如
say
或logout
。 - 服务器应该易于扩展。
需要特殊工具的两件事是网络连接和程序的异步特性。
有用的工具
这个项目中唯一需要的新工具是标准库中的asyncore
模块及其相关的asynchat
。我将描述这些是如何工作的基础。您可以在 Python 库参考中找到关于它们的更多详细信息。正如在第十四章中所讨论的,网络程序的基本组件是套接字。通过导入socket
模块并使用那里的函数可以直接创建套接字。那么你需要asyncore
做什么?
asyncore
框架使您能够同时处理几个连接的用户。想象一个场景,你没有特殊的工具来处理这个问题。当您启动服务器时,它会等待用户连接。当一个用户被连接时,它开始从该用户读取数据,并通过套接字提供结果。但是如果另一个用户已经连接了,会发生什么呢?第二个要连接的用户必须等到第一个完成。在某些情况下,这样做很好,但是当你编写一个聊天服务器时,关键是可以连接不止一个用户——否则用户之间如何聊天?
asyncore
框架基于一种底层机制(来自select
模块的select
函数,如第十四章所述),它允许服务器以渐进的方式为所有连接的用户提供服务。不是先从一个用户读取所有可用数据,然后再继续下一个用户,而是只读取一些数据。此外,服务器只从有数据要读取的套接字读取数据。如此循环往复,一次又一次。以类似的方式处理书写。你可以自己使用模块socket
和select
来实现,但是asyncore
和asynchat
为你提供了一个非常有用的框架来处理细节。(有关实现并行用户连接的替代方法,请参见第十四章中的“多重连接”一节。)
准备
你首先需要的是一台联网的电脑(比如互联网);否则,其他人将无法连接到您的聊天服务器。(从您自己的机器连接到聊天服务器是可能的,但是从长远来看,这可能不太有趣。)为了能够连接,用户必须知道您机器的地址(机器名,如foo.bar.baz.com
或 IP 地址)。此外,用户必须知道您的服务器使用的端口号。您可以在您的程序中设置这一点;在本章的代码中,我使用了(相当随意的)端口号 5005。
Note
如第十四章所述,某些端口号受到限制,需要管理员权限。一般来说,大于 1023 的数字是可以的。
为了测试你的服务器,你需要一个客户端——交互用户端的程序。这类事情的一个简单程序是telnet
(它基本上允许您连接到任何套接字服务器)。在 UNIX 中,您可能可以在命令行上使用这个程序。
$ telnet some.host.name 5005
前面的命令连接到端口 5005 上的机器some.host.name
。要连接到运行telnet
命令的同一台机器,只需使用机器名localhost
。(你可能想通过-e
开关提供一个转义字符,以确保你可以轻松退出telnet
。详见telnet
文件。)
在 Windows 中,您可以使用具有telnet
功能的终端仿真器,例如 PuTTY(软件和更多信息可在 http://www.chiark.greenend.org.uk/~sgtatham/putty
获得)。然而,如果你正在安装新的软件,你还不如买一个专门为聊天定制的客户端程序。MUD(或 MUSH 或 MOO 或其他一些相关的缩写)客户端 1 非常适合这类事情。一个选项是 TinyFugue(软件和更多信息可在 http://tinyfugue.sf.net
获得)。它主要是为在 UNIX 中使用而设计的。(一些客户端也可用于 Windows 只要在网上搜索“泥浆客户”或类似的东西。)这一切都有点“老派”,但这只是魅力的一部分。
首次实施
让我们把事情分解一下。我们需要创建两个主要的类:一个代表聊天服务器,一个代表每个聊天会话(连接的用户)。
ChatServer 类
为了创建基本的ChatServer
,你从asyncore
中继承了dispatcher
类。dispatcher
基本上只是一个 socket 对象,但是有一些额外的事件处理特性,您马上就会用到。参见清单 24-1 中的一个基本的聊天服务器程序(它做的很少)。
from asyncore import dispatcher
import asyncore
class ChatServer(dispatcher): pass
s = ChatServer()
asyncore.loop()
Listing 24-1.A Minimal Server Program
如果你运行这个程序,什么也不会发生。为了让服务器做任何有趣的事情,您应该调用它的create_socket
方法来创建一个套接字,调用它的bind
和listen
方法来将套接字绑定到一个特定的端口号,并告诉它监听传入的连接。(毕竟,这就是服务器的作用。)此外,您将覆盖handle_accept
事件处理方法,以便在服务器接受客户端连接时实际执行一些操作。结果程序如清单 24-2 所示。
from asyncore import dispatcher
import socket, asyncore
class ChatServer(dispatcher):
def handle_accept(self):
conn, addr = self.accept()
print('Connection attempt from', addr[0])
s = ChatServer()
s.create_socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('', 5005))
s.listen(5)
asyncore.loop()
Listing 24-2.A Server That Accepts Connections
handle_accept
方法调用self.accept
,让客户端连接。这将返回一个连接(特定于该客户机的套接字)和一个地址(关于哪台机器正在连接的信息)。handle_accept
方法没有对这个连接做任何有用的事情,只是打印出一个连接尝试。addr[0]
是客户端的 IP 地址。
服务器初始化使用两个参数调用create_socket
,这两个参数指定了您想要的套接字类型。您可以使用不同的类型,但这里显示的是您通常需要的类型。对bind
方法的调用只是将服务器绑定到一个特定的地址(主机名和端口)。主机名为空(一个空字符串,本质上意味着 localhost,或者更专业地说,“这台机器上的所有接口”),端口号为 5005。对listen
的调用告诉服务器监听连接;它还指定了五个连接的积压。对asyncore.loop
的最后一次调用像以前一样启动服务器的监听循环。
这个服务器实际上是工作的。尝试运行它,然后用您的客户端连接到它。客户端应该立即断开连接,服务器应该打印出以下内容:
Connection attempt from 127.0.0.1
如果您不从与服务器相同的机器连接,IP 地址将会不同。要停止服务器,只需使用键盘中断:UNIX 中的 Ctrl+C 或 Windows 中的 Ctrl+Break。
使用键盘中断关闭服务器会导致堆栈跟踪。为了避免这种情况,您可以将loop
包装在一个try
/ except
语句中。通过一些其他的清理,基本服务器最终如清单 24-3 所示。
from asyncore import dispatcher
import socket, asyncore
PORT = 5005
class ChatServer(dispatcher):
def __init__(self, port):
dispatcher.__init__(self)
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
self.bind(('', port))
self.listen(5)
def handle_accept(self):
conn, addr = self.accept()
print('Connection attempt from', addr[0])
if __name__ == '__main__':
s = ChatServer(PORT)
try: asyncore.loop()
except KeyboardInterrupt: pass
Listing 24-3.The Basic Server with Some Cleanups
添加的对set_reuse_addr
的调用允许您重用同一个地址(特别是端口号),即使服务器没有正确关闭。(如果没有这个调用,您可能需要等待一段时间才能再次启动服务器,或者在服务器每次崩溃时更改端口号,因为您的程序可能无法正确地通知您的操作系统它已经完成了端口。)
聊天会话类
基本的ChatServer
不是很有用。应该为每个连接创建一个新的dispatcher
对象,而不是忽略连接尝试。但是,这些对象的行为与用作主服务器的对象不同。他们不会在端口上侦听传入的连接;他们已经连接到客户端。他们的主要任务是收集来自客户端的数据(文本)并对其做出响应。您可以通过子类化dispatcher
并覆盖各种方法来自己实现这个功能,但是,幸运的是,有一个模块已经完成了大部分工作:asynchat
。
尽管名字如此,asynchat
并不是专门为我们正在开发的流(连续)聊天应用而设计的。(名称中的chat
指的是“聊天式”或命令响应协议。)关于async_chat
类(可以在asynchat
模块中找到)的好处是,它隐藏了最基本的套接字读写操作,这可能有点难以做到。让它工作所需要的就是覆盖两个方法:collect_incoming_data
和found_terminator
。前者在每次从套接字读取一点文本时调用,后者在读取终止符时调用。终止符(在这种情况下)只是一个换行符。(作为初始化的一部分,您需要通过调用set_terminator
来告诉async_chat
对象这一点。)
清单 24-4 显示了一个更新的程序,现在有了一个ChatSession
类。
from asyncore import dispatcher
from asynchat import async_chat
import socket, asyncore
PORT = 5005
class ChatSession(async_chat):
def __init__(self, sock):
async_chat. init (self, sock)
self.set_terminator("\r\n")
self.data = []
def collect_incoming_data(self, data):
self.data.append(data)
def found_terminator(self):
line = ''.join(self.data)
self.data = []
# Do something with the line...
print(line)
class ChatServer(dispatcher):
def __init__(self, port): dispatcher. init (self)
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
self.bind(('', port))
self.listen(5)
self.sessions = []
def handle_accept(self):
conn, addr = self.accept()
self.sessions.append(ChatSession(conn))
if __name__ == '__main__':
s = ChatServer(PORT)
try: asyncore.loop()
except KeyboardInterrupt: print()
Listing 24-4.Server Program with ChatSession Class
在这个新版本中,有几样东西一文不值。
- 使用
set_terminator
方法将行终止符设置为"\r\n"
,这是网络协议中常用的行终止符。 ChatSession
对象将目前已经读取的数据保存为一个名为data
的字符串列表。当读取更多数据时,collect_incoming_data
被自动调用,它只是将数据添加到列表中。使用一列字符串,然后将它们连接起来(用join
string 方法)是一种常见的习惯用法(从历史上看,这比递增地添加字符串更有效)。请随意使用带字符串的+=
。- 当找到一个终止符时,调用
found_terminator
方法。当前实现通过连接当前数据项创建一行,并将self.data
重置为空列表。但是,因为您还没有对该行做任何有用的事情,所以它只是被打印出来。 ChatServer
保存会话列表。ChatServer
的handle_accept
方法现在创建一个新的ChatSession
对象,并将其添加到会话列表中。
尝试运行服务器并同时连接两个(或更多)客户端。您在客户机中键入的每一行都应该打印在运行服务器的终端上。这意味着服务器现在能够同时处理几个连接。现在所缺少的是让客户看到其他人在说什么的能力!
把它放在一起
在原型被认为是一个功能齐全(虽然简单)的聊天服务器之前,还缺少一个主要的功能:用户所说的话(他们键入的每一行)应该被广播给其他人。该功能可以通过服务器中的一个简单的for
循环来实现,该循环遍历会话列表并将该行写入每个会话。要将数据写入一个async_chat
对象,您可以使用push
方法。
这种广播行为还增加了另一个问题:当客户端断开连接时,您必须确保从列表中删除连接。您可以通过覆盖事件处理方法handle_close
来做到这一点。第一个原型的最终版本可以在清单 24-5 中看到。
from asyncore import dispatcher
from asynchat import async_chat
import socket, asyncore
PORT = 5005
NAME = 'TestChat'
class ChatSession(async_chat):
"""
A class that takes care of a connection between the server and a single user.
"""
def __init__(self, server, sock):
# Standard setup tasks:
async_chat. init (self, sock)
self.server = server
self.set_terminator("\r\n")
self.data = []
# Greet the user:
self.push('Welcome to %s\r\n' % self.server.name)
def collect_incoming_data(self, data):
self.data.append(data)
def found_terminator(self):
"""
If a terminator is found, that means that a full
line has been read. Broadcast it to everyone.
"""
line = ''.join(self.data)
self.data = []
self.server.broadcast(line)
def handle_close(self):
async_chat.handle_close(self)
self.server.disconnect(self)
class ChatServer(dispatcher):
"""
A class that receives connections and spawns individual
sessions. It also handles broadcasts to these sessions.
"""
def __init__(self, port, name):
# Standard setup tasks dispatcher. init (self)
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
self.bind(('', port))
self.listen(5)
self.name = name
self.sessions = []
def disconnect(self, session):
self.sessions.remove(session)
def broadcast(self, line):
for session in self.sessions:
session.push(line + '\r\n')
def handle_accept(self):
conn, addr = self.accept()
self.sessions.append(ChatSession(self, conn))
if __name__ == '__main__':
s = ChatServer(PORT, NAME)
try: asyncore.loop()
except KeyboardInterrupt: print()
Listing 24-5.A Simple Chat Server
(simple_chat.py)
第二次实施
第一个原型可能是一个全功能的聊天服务器,但是它的功能非常有限。最明显的限制是你不能辨别谁在说什么。此外,它不解释原始规范要求的命令(如say
或logout
)。因此,您需要添加对身份(每个用户一个唯一的名称)和命令解释的支持,并且您必须使每个会话的行为取决于它所处的状态(刚刚连接、登录等等)——所有这些都以一种易于扩展的方式进行。
基本命令解释
我将向您展示如何在标准库中的cmd
模块的Cmd
类上建模命令解释。(不幸的是,您不能直接使用这个类,因为它只能与sys.stdin
和sys.stdout
一起使用,并且您正在处理几个流。)你需要的是一个可以处理单行文本(由用户键入)的函数或方法。它应该将第一个单词(命令)分离出来,并基于它调用一个适当的方法。例如,这一行:
say Hello, world!
可能会导致以下调用:
do_say('Hello, world!')
可能将会话本身作为一个附加参数(这样do_say
就会知道是谁在说话)。
下面是一个简单的实现,增加了一个方法来表示命令未知:
class CommandHandler:
"""
Simple command handler similar to cmd.Cmd from the standard library.
"""
def unknown(self, session, cmd):
session.push('Unknown command: {}s\r\n'.format(cmd))
def handle(self, session, line):
if not line.strip(): return
parts = line.split(' ', 1)
cmd = parts[0]
try: line = parts[1].strip()
except IndexError: line = ''
meth = getattr(self, 'do_' + cmd, None)
try:
meth(session, line)
except TypeError:
self.unknown(session, cmd)
在这个类中getattr
的使用类似于第二十章中的标记项目。基本的命令处理已经完成,您需要定义一些实际的命令。哪些命令可用(以及它们做什么)应该取决于会话的当前状态。你如何代表那个州?
空间
每个状态都可以由一个自定义命令处理程序来表示。这很容易与聊天室(或 MUD 中的位置)的标准概念相结合。每个房间都是一个CommandHandler
,有自己专门的命令。此外,它应该跟踪哪些用户(会话)当前在其中。以下是适用于所有房间的通用超类:
class EndSession(Exception): pass
class Room(CommandHandler):
"""
A generic environment which may contain one or more users (sessions).
It takes care of basic command handling and broadcasting.
"""
def __init__(self, server):
self.server = server
self.sessions = []
def add(self, session):
self.sessions.append(session)
def remove(self, session):
self.sessions.remove(session)
def broadcast(self, line):
for session in self.sessions:
session.push(line)
def do_logout(self, session, line):
raise EndSession
除了基本的add
和remove
方法之外,一个broadcast
方法简单地在房间中的所有用户(会话)上调用push
。还定义了一个命令— logout
(以do_logout
方法的形式)。它会引发一个异常(EndSession
),该异常会在更高级别的处理中处理(在found_terminator
)。
登录和注销室
除了表示普通的聊天室(这个项目只包含一个这样的聊天室)之外,Room
子类还可以表示其他状态,这确实是我们的初衷。例如,当一个用户连接到服务器时,他被放入一个专用的LoginRoom
(其中没有其他用户)。当用户进入时,LoginRoom
打印一条欢迎消息(在add
方法中)。它还覆盖了unknown
方法来告诉用户登录;它唯一响应的命令是login
命令,该命令检查名称是否可接受(不是空字符串,也没有被其他用户使用)。
LogoutRoom
就简单多了。它唯一的工作是从服务器上删除用户名(服务器上有一个名为users
的字典,用于存储会话)。如果这个名字不存在(因为用户从未登录过),那么产生的KeyError
将被忽略。
有关这两个类的源代码,请参见本章后面的清单 24-6 。
Note
即使服务器的users
字典保存了对所有会话的引用,也不会从中检索到任何会话。users
字典仅用于跟踪哪些名称在使用中。然而,我决定让每个用户名引用相应的会话,而不是使用任意的值(比如True
)。尽管没有立即使用它,但它在程序的某个较新版本中可能是有用的(例如,如果一个用户想私下给另一个用户发送消息)。另一种方法是简单地保存一组或一个会话列表。
主聊天室
主聊天室也覆盖了add
和remove
方法。在add
中,它广播一条关于正在进入的用户的消息,并将该用户的名字添加到服务器中的users
字典中。remove
方法广播关于用户离开的消息。
除了这些方法,ChatRoom
类还实现了三个命令。
say
命令(由do_say
实现)广播一行,前缀是说话的用户的名字。look
命令(由do_look
执行)告诉用户哪些用户当前在房间里。who
命令(由do_who
执行)告诉用户哪些用户当前已经登录。在这个简单的服务器中,look
和who
是等效的,但是如果您将其扩展为包含多个房间,它们的功能将会不同。
有关源代码,请参见本章后面的清单 24-6 。
新服务器
我已经描述了大部分的功能。对ChatSession
和ChatServer
的主要补充如下:
ChatSession
有一个叫enter
的方法,用来进入一个新房间。ChatSession
构造函数使用LoginRoom
。handle_close
方法使用LogoutRoom
。ChatServer
构造函数将字典users
和名为main_room
的ChatRoom
添加到它的属性中。
还要注意handle_accept
不再将新的ChatSession
添加到会话列表中,因为会话现在由房间管理。
Note
一般来说,如果你只是简单地实例化一个对象,像handle_accept
中的ChatSession
,而没有给它绑定一个名字或者把它添加到一个容器中,那么它就会丢失,可能会被垃圾回收(也就是说它会彻底消失)。因为所有的调度器都是由asyncore
(而async_chat
是dispatcher
的子类)处理(引用)的,所以这在这里不是问题。
聊天服务器的最终版本如清单 24-6 所示。为了方便起见,我在表 24-1 中列出了可用的命令。
表 24-1。
Commands Available in the Chat Server
| 命令 | 有售 | 描述 | | --- | --- | --- | | `login name` | 登录室 | 用于登录到服务器 | | `logout` | 所有房间 | 用于从服务器注销 | | `say statement` | 聊天室 | 曾经说过的话 | | `look` | 聊天室 | 用来找出谁在同一个房间 | | `who` | 聊天室 | 用于找出登录到服务器的用户 |from asyncore import dispatcher
from asynchat import async_chat
import socket, asyncore
PORT = 5005
NAME = 'TestChat'
class EndSession(Exception): pass
class CommandHandler:
"""
Simple command handler similar to cmd.Cmd from the standard library.
"""
def unknown(self, session, cmd):
'Respond to an unknown command'
session.push('Unknown command: {}s\r\n'.format(cmd))
def handle(self, session, line):
'Handle a received line from a given session'
if not line.strip(): return
# Split off the command:
parts = line.split(' ', 1)
cmd = parts[0]
try: line = parts[1].strip()
except IndexError: line = ''
# Try to find a handler:
meth = getattr(self, 'do_' + cmd, None)
try:
# Assume it's callable:
meth(session, line)
except TypeError:
# If it isn't, respond to the unknown command:
self.unknown(session, cmd)
class Room(CommandHandler):
"""
A generic environment that may contain one or more users (sessions).
It takes care of basic command handling and broadcasting.
"""
def __init__(self, server):
self.server = server
self.sessions = []
def add(self, session):
'A session (user) has entered the room'
self.sessions.append(session)
def remove(self, session):
'A session (user) has left the room'
self.sessions.remove(session)
def broadcast(self, line):
'Send a line to all sessions in the room'
for session in self.sessions:
session.push(line)
def do_logout(self, session, line):
'Respond to the logout command'
raise EndSession
class LoginRoom(Room):
"""
A room meant for a single person who has just connected.
"""
def add(self, session):
Room.add(self, session)
# When a user enters, greet him/her:
self.broadcast('Welcome to {}\r\n'.format(self.server.name))
def unknown(self, session, cmd):
# All unknown commands (anything except login or logout)
# results in a prodding:
session.push('Please log in\nUse "login <nick>"\r\n')
def do_login(self, session, line):
name = line.strip()
# Make sure the user has entered a name:
if not name:
session.push('Please enter a name\r\n')
# Make sure that the name isn't in use:
elif name in self.server.users:
session.push('The name "{}" is taken.\r\n'.format(name))
session.push('Please try again.\r\n')
else:
# The name is OK, so it is stored in the session, and
# the user is moved into the main room. session.name = name
session.enter(self.server.main_room)
class ChatRoom(Room):
"""
A room meant for multiple users who can chat with the others in the room.
"""
def add(self, session):
# Notify everyone that a new user has entered:
self.broadcast(session.name + ' has entered the room.\r\n')
self.server.users[session.name] = session
super().add(session)
def remove(self, session):
Room.remove(self, session)
# Notify everyone that a user has left:
self.broadcast(session.name + ' has left the room.\r\n')
def do_say(self, session, line):
self.broadcast(session.name + ': ' + line + '\r\n')
def do_look(self, session, line):
'Handles the look command, used to see who is in a room'
session.push('The following are in this room:\r\n')
for other in self.sessions:
session.push(other.name + '\r\n')
def do_who(self, session, line):
'Handles the who command, used to see who is logged in'
session.push('The following are logged in:\r\n')
for name in self.server.users:
session.push(name + '\r\n')
class LogoutRoom(Room):
"""
A simple room for a single user. Its sole purpose is to remove the
user's name from the server.
"""
def add(self, session):
# When a session (user) enters the LogoutRoom it is deleted
try: del self.server.users[session.name]
except KeyError: pass
class ChatSession(async_chat):
"""
A single session, which takes care of the communication with a single user.
"""
def __init__(self, server, sock):
super().__init__(sock)
self.server = server
self.set_terminator("\r\n")
self.data = []
self.name = None
# All sessions begin in a separate LoginRoom:
self.enter(LoginRoom(server))
def enter(self, room):
# Remove self from current room and add self to
# next room...
try: cur = self.room
except AttributeError: pass
else: cur.remove(self)
self.room = room
room.add(self)
def collect_incoming_data(self, data):
self.data.append(data)
def found_terminator(self):
line = ''.join(self.data)
self.data = []
try: self.room.handle(self, line)
except EndSession: self.handle_close()
def handle_close(self):
async_chat.handle_close(self)
self.enter(LogoutRoom(self.server))
class ChatServer(dispatcher):
"""
A chat server with a single room.
"""
def __init__(self, port, name):
super().__init__()
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
self.bind(('', port))
self.listen(5)
self.name = name
self.users = {}
self.main_room = ChatRoom(self)
def handle_accept(self):
conn, addr = self.accept()
ChatSession(self, conn)
if __name__ == '__main__':
s = ChatServer(PORT, NAME)
try: asyncore.loop()
except KeyboardInterrupt: print()
Listing 24-6.A Slightly More Complicated Chat Server (chatserver.py
)
聊天会话的示例如图 24-1 所示。该示例中的服务器是用以下命令启动的:
图 24-1。
A sample chat session
python chatserver.py
用户dilbert
使用以下命令连接到服务器:
telnet localhost 5005
进一步探索
您可以做很多事情来扩展和增强本章中介绍的基本服务器。
- 你可以创建一个有多个聊天室的版本,你可以扩展命令集,让它以你想要的任何方式运行。
- 你可能想让程序只识别某些命令(如
login
或logout
),而将所有其他输入的文本视为普通聊天,从而避免使用say
命令。 - 您可以在所有命令前添加一个特殊字符(例如,一个斜杠,给出像
/login
和/logout
这样的命令),并将所有不以指定字符开头的内容视为一般聊天。 - 您可能想创建自己的 GUI 客户端,但这比看起来要复杂一些。GUI 工具包有一个事件循环,与服务器的通信可能需要另一个事件循环。为了让它们合作,你可能需要使用线程。(关于如何在不同线程不直接访问彼此数据的简单情况下实现这一点的示例,请参见第二十八章。)
什么现在?
现在你有了自己的聊天服务器。在下一个项目中,我们将处理不同类型的网络编程:CGI,许多 web 应用的底层机制(如第十五章所讨论的)。这项技术在下一个项目中的具体应用是远程编辑,它使几个用户能够协作开发同一个文档。你甚至可以用它来远程编辑你自己的网页。
Footnotes 1
MUD 代表多用户地下城/域/维度。MUSH 代表多用户共享幻觉。MOO 的意思是 MUD,面向对象。
二十五、项目 6:CGI 远程编辑
本章的项目使用 CGI,在第十五章会有更详细的讨论。具体应用是远程编辑——通过网络在另一台机器上编辑文档。这在协作系统(群件)中很有用,例如,几个人可能在处理同一个文档。它也可以用于更新您的网页。
有什么问题?
您在一台机器上存储了一个文档,并希望能够通过 Web 从另一台机器上编辑它。这使您能够拥有由几个协作作者编辑的共享文档。您不需要使用 FTP 或类似的文件传输技术,也不需要担心同步多个副本。要编辑这个文件,你只需要一个网络浏览器。
Note
这种远程编辑是维基的核心机制之一(例如, http://en.wikipedia.org/wiki/Wiki
)。
具体而言,系统应满足以下要求:
- 它应该能够将文档显示为正常的网页。
- 它应该能够在 web 表单的文本区域显示文档。
- 您应该能够保存表单中的文本。
- 程序应该用密码保护文档。
- 该程序应该易于扩展,以支持编辑多个文档。正如您将看到的,使用标准的 Python 库模块
cgi
和一些普通的 Python 编码,所有这些都很容易做到。然而,该应用中使用的技术可以用于创建所有 Python 程序的 web 接口,因此非常有用。
有用的工具
正如在第十五章中所讨论的,编写 CGI 程序的主要工具是cgi
模块,以及用于调试的cgitb
模块。更多信息参见第十五章。
准备
在第十五章的“使用 CGI 的动态网页”一节中详细描述了通过网络访问 CGI 脚本的步骤只要按照这些步骤去做,你应该会没事的。
首次实施
第一个实现基于清单 15-7 所示的问候脚本的基本结构(第十五章)。第一个原型所需要的只是一些文件处理。
为了使脚本有用,它必须在调用之间存储编辑过的文本。此外,表单应该比问候脚本(第十五章中的清单 15-7 中的simple3.cgi
)中的要大一点,文本字段应该变成一个文本区域。你也应该使用POST
CGI 方法,而不是默认的GET
方法。(如果您正在提交大量数据,通常应该使用POST
。)
程序的一般逻辑如下:
- 以数据文件的当前值为默认值获取 CGI 参数
text
。 - 将文本保存到数据文件中。
- 打印出表格,文本在
textarea
中。
为了允许脚本写入您的数据文件,您必须首先创建这样一个文件(例如,simple_edit.dat
)。它可以是空的,也可以包含初始文档(一个纯文本文件,可能包含某种形式的标记,如 XML 或 HTML)。然后你必须设置权限,使它是通用可写的,如第十五章所述。结果代码如清单 25-1 所示。
#!/usr/bin/env python
import cgi
form = cgi.FieldStorage()
text = form.getvalue('text', open('simple_edit.dat').read())
f = open('simple_edit.dat', 'w')
f.write(text)
f.close()
print("""Content-type: text/html
<html>
<head>
<title>A Simple Editor</title>
</head>
<body>
<form action='simple_edit.cgi' method='POST'>
<textarea rows='10' cols='20' name='text'>{}</textarea><br />
<input type='submit' />
</form>
</body>
</html>
""".format(text))
Listing 25-1.A Simple Web Editor
(simple_edit.cgi)
当通过 web 服务器访问时,CGI 脚本检查名为text
的输入值。如果提交了这样的值,文本将被写入文件simple_edit.dat
。默认值是文件的当前内容。最后显示一个网页(包含编辑和提交文本的字段),如图 25-1 所示。
图 25-1。
The simple_edit.cgi script in action
第二次实施
现在你有了第一辆上路的原型车,还缺什么?系统应该能够编辑多个文件,并且应该使用密码保护。(因为文档可以通过直接在浏览器中打开来查看,所以您不会太注意系统的查看部分。)
与第一个原型的主要区别在于,你将它分成两个独立的 CGI 脚本(一个用于你的系统应该能够执行的每个“动作”)。新原型的文件如下:
- 一个普通的网页,带有一个可以输入文件名的表单。它还有一个打开按钮,可以触发
edit.cgi
。 edit.cgi
:在文本区域显示给定文件的脚本。它有一个用于输入密码的文本字段和一个触发save.cgi
的保存按钮。save.cgi
:将接收到的文本保存到给定文件并显示简单消息(例如,“文件已保存”)的脚本。这个脚本还应该负责密码检查。
让我们逐一解决这些问题。
创建文件名表单
index.html
是一个 HTML 文件,包含用于输入文件名的表单。
<html>
<head>
<title>File Editor</title>
</head>
<body>
<form action='edit.cgi' method='POST'>
<b>File name:</b><br />
<input type='text' name='filename' />
<input type='submit' value='Open' />
</body>
</html>
注意文本字段是如何命名的filename
。这确保了它的内容将作为 CGI 参数filename
提供给edit.cgi
脚本(这是form
标签的action
属性)。如果您在浏览器中打开该文件,在文本字段中输入文件名,然后单击 open,将会运行edit.cgi
脚本。
编写编辑器脚本
由edit.cgi
脚本显示的页面应该包括一个包含您正在编辑的文件的当前文本的文本区域和一个用于输入密码的文本字段。唯一需要的输入是文件名,脚本从index.html
中的表单接收文件名。但是,请注意,可以直接打开edit.cgi
脚本,而无需在index.html
中提交表单。在这种情况下,你不能保证cgi.FieldStorage
的filename
字段被设置。所以你需要添加一个检查来确保有一个文件名。如果有,文件将从包含可编辑文件的目录中打开。我们把目录叫做data
。(当然,您必须创建这个目录。)
Caution
请注意,通过提供包含路径元素的文件名,例如…(两点),就有可能访问这个目录之外的文件。为了确保被访问的文件在给定的目录中,您应该执行一些额外的检查,例如列出目录中的所有文件(例如使用glob
模块)并检查所提供的文件名是否是候选文件之一(确保您使用完整的绝对路径名)。另一种方法见第二十七章中的“验证文件名”一节。
然后,代码变成类似清单 25-2 的东西。
#!/usr/bin/env python
print('Content-type: text/html\n')
from os.path import join, abspath
import cgi, sys
BASE_DIR = abspath('data')
form = cgi.FieldStorage()
filename = form.getvalue('filename')
if not filename:
print('Please enter a file name')
sys.exit()
text = open(join(BASE_DIR, filename)).read()
print("""
<html>
<head>
<title>Editing...</title>
</head>
<body>
<form action='save.cgi' method='POST'>
<b>File:</b> {}<br />
<input type='hidden' value='{}' name='filename' />
<b>Password:</b><br />
<input name='password' type='password' /><br />
<b>Text:</b><br />
<textarea name='text' cols='40' rows='20'>{}</textarea><br />
<input type='submit' value='Save' />
</form>
</body>
</html>
""".format(filename, filename, text))
Listing 25-2.The Editor Script (edit.cgi)
注意,abspath
函数已经被用来获取data
目录的绝对路径。还要注意,文件名已经存储在一个hidden
表单元素中,因此它将被传递给下一个脚本(save.cgi
),而不会给用户任何修改的机会。(当然,你无法保证这一点,因为用户可能会编写自己的表单,将它们放在另一台机器上,并让这些表单用自定义值调用你的 CGI 脚本。)
对于密码处理,示例代码使用类型为password
的输入元素,而不是text
,这意味着输入的字符将显示为星号。
Note
这个脚本基于一个假设,即给定的文件名指的是一个现有的文件。请随意扩展它,以便它也可以处理其他情况。
编写保存脚本
执行保存的脚本是这个简单系统的最后一个组件。它接收文件名、密码和一些文本。它检查密码是否正确,如果正确,程序将文本存储在具有给定文件名的文件中。(该文件应正确设置其权限;参见第十五章中关于设置文件权限的讨论。)
只是为了好玩,我们将在密码处理中使用sha
模块。安全哈希算法(SHA)是一种从输入字符串中提取看似随机的实际上无意义的数据字符串(摘要)的方法。该算法背后的思想是,构造一个具有给定摘要的字符串几乎是不可能的,所以如果你知道(例如)一个密码的摘要,你就没有(容易的)方法来重建密码或发明一个将再现摘要的密码。这意味着您可以安全地将提供的密码摘要与存储的(正确密码的)摘要进行比较,而不是比较密码本身。通过使用这种方法,您不需要将密码本身存储在源代码中,阅读代码的人也不会知道密码实际上是什么。
Caution
我说过,这个“安全”功能主要是为了好玩。除非您使用 SSL 或类似技术的安全连接(这超出了本项目的范围),否则仍然有可能获得通过网络提交的密码。此外,这里使用的 SHA1 算法不再被认为是特别安全的。
下面是一个如何使用sha
的例子:
>>> from hashlib import sha1
>>> sha1(b'foobar').hexdigest()
'8843d7f92416211de9ebb963ff4ce28125932878'
>>> sha1(b'foobaz').hexdigest()
'21eb6533733a5e4763acacd1d45a60c2e0e404e1'
正如你所看到的,密码的一个小变化会给你一个完全不同的摘要。你可以在清单 25-3 中看到save.cgi
的代码。
#!/usr/bin/env python
print('Content-type: text/html\n')
from os.path import join, abspath
from hashlib import sha1
import cgi, sys
BASE_DIR = abspath('data')
form = cgi.FieldStorage()
text = form.getvalue('text')
filename = form.getvalue('filename')
password = form.getvalue('password')
if not (filename and text and password):
print('Invalid parameters.')
sys.exit()
if sha1(password.encode()).hexdigest() != '8843d7f92416211de9ebb963ff4ce28125932878':
print('Invalid password')
sys.exit()
f = open(join(BASE_DIR,filename), 'w')
f.write(text)
f.close()
print('The file has been saved.')
Listing 25-3.The Saving Script (save.cgi)
运行编辑器
按照以下步骤使用编辑器:
-
Open the page
index.html
in a web browser. Be sure to open it through a web server (by using a URL of the formhttp://www.someserver.com/index.html
) and not as a local file. The result is shown in Figure 25-2.图 25-2。
The opening page of the CGI editor
-
Enter a file name of a file that your CGI editor is permitted to modify and then click Open. Your browser should then contain the output of the
edit.cgi
script, as shown in Figure 25-3.图 25-3。
The editing page of the CGI editor
-
根据个人喜好编辑文件,输入密码(您自己设置的密码,或者示例中使用的密码
foobar
),然后单击 Save。然后,您的浏览器应该包含save.cgi
脚本的输出,这只是一条消息“文件已保存” -
如果要验证文件是否已被修改,请重复打开文件的过程(步骤 1 和 2)。
进一步探索
使用本项目中展示的技术,您可以开发各种 web 系统。对现有系统的一些可能的补充如下:
- 添加版本控制。保存已编辑文件的旧副本,以便您可以“撤销”您的更改。
- 添加对用户名的支持,这样您就知道谁更改了什么。
- 添加文件锁定(例如,使用
fcntl
模块),这样两个用户就不能同时编辑文件。 - 添加一个自动向文件添加标记的
view.cgi
脚本(就像第二十章中的那个)。 - 通过更彻底地检查脚本的输入并添加更多用户友好的错误消息,使脚本更加健壮。
- 避免打印类似“文件已保存”的确认消息您可以添加一些更有用的输出,或者将用户重定向到另一个页面/脚本。重定向可以用
Location
头来完成,它的工作方式类似于Content-type
。只需在输出的头部分添加Location:
,后跟一个空格和一个 URL(在第一个空行之前)。
除了扩展这个 CGI 系统的功能之外,你可能想要为 Python 测试一些更复杂的 web 环境(如第十五章所讨论的)。
什么现在?
现在你已经尝试过编写 CGI 脚本了。在下一个项目中,您将使用 SQL 数据库进行存储。有了这种强大的组合,您将实现一个全功能的基于 web 的公告板。