Python-入门指南-从新手到大师-八-

113 阅读1小时+

Python 入门指南:从新手到大师(八)

原文:Beginning Python

协议:CC BY-NC-SA 4.0

二十六、项目 7:你自己的公告板

许多种类的软件使你能够在互联网上与其他人交流。你已经看到了一些(例如,第二十三章的新闻组组和第二十四章的聊天服务器)。在本章中,您将实现另一个这样的系统:一个基于 web 的论坛。虽然功能与复杂的社交媒体平台相去甚远,但它确实实现了评论系统所需的基本功能。

有什么问题?

在本项目中,您将创建一个通过 Web 发布和回复消息的简单系统。作为一个论坛,这本身就很有用。本章中开发的系统非常简单,但是基本的功能是有的,它应该能够处理大量的帖子。

然而,本章涵盖的材料除了开发独立的论坛之外还有其他用途。例如,它可以用来实现一个更通用的协作系统,或者一个问题跟踪系统,一个具有评论功能的博客,或者一些完全不同的东西。CGI(或类似的技术)和一个可靠的数据库(在这里是一个 SQL 数据库)的结合是非常强大和通用的。

Tip

尽管编写自己的代码既有趣又有教育意义,但在许多情况下,搜索现有的解决方案更划算。在论坛之类的情况下,您可能会发现相当多的开发良好的系统是免费提供的。此外,大多数 web 应用框架(在第十五章中讨论)都内置了对这种功能的支持。

具体来说,最终系统应满足以下要求:

  • 它应该显示所有当前消息的主题。
  • 它应该支持消息线程(在他们回复的消息下显示缩进的回复)。
  • 您应该能够查看现有的消息。
  • 您应该能够回复现有的消息。

除了这些功能需求之外,如果系统相当稳定,能够处理大量消息,并且避免两个用户同时写入同一个文件这样的问题,那就更好了。通过使用某种类型的数据库服务器,而不是自己编写文件处理代码,可以实现所需的健壮性。

有用的工具

除了第十五章中的 CGI 内容,你还需要一个 SQL 数据库,如第十三章中所讨论的。您可以使用独立的数据库 SQLite(在该章中使用),也可以使用其他系统,例如以下两个优秀的免费数据库中的任何一个:

在这一章中,我使用 PostgreSQL 作为例子,但是代码只需稍加编辑就可以用于大多数 SQL 数据库(包括 MySQL 或 SQLite)。

在继续之前,您应该确保能够访问 SQL 数据库服务器(或独立的 SQL 数据库,如 SQLite ),并查看其文档以了解如何管理它。

除了数据库服务器本身,您还需要一个能够与服务器交互的 Python 模块(并对您隐藏细节)。大多数这样的模块支持 Python DB API,这将在第十三章中详细讨论。在这一章中,我使用了psycopg ( http://initd.org ),PostgreSQL 的一个健壮前端。如果你正在使用 MySQL,那么MySQLdb模块( http://sourceforge.net/projects/mysql-python )是一个不错的选择。

安装完数据库模块后,您应该能够导入它(例如,用import psycopgimport MySQLdb)而不会引发任何异常。

准备

在您的程序可以开始使用您的数据库之前,您必须实际创建数据库。这是使用 SQL 完成的(参见第十三章中的一些指针)。

数据库结构与问题有着密切的联系,一旦创建了数据库并用数据(消息)填充了数据库,就很难改变它。让我们保持简单。

您将只有一个表,其中每条消息占一行。每条消息都有一个唯一的 ID(一个整数)、一个主题、一个发件人(或发帖人)和一些文本(正文)。

此外,因为您希望能够分层显示消息(线程),所以每条消息都应该存储一个对它所回复的消息的引用。产生的CREATE TABLE SQL 命令如清单 26-1 所示。

CREATE TABLE messages (
    id          SERIAL PRIMARY KEY,
    subject     TEXT NOT NULL,
    sender      TEXT NOT NULL,
    reply_to    INTEGER REFERENCES messages,
    text        TEXT NOT NULL
);

Listing 26-1.Creating the Database in PostgreSQL

注意,这个命令使用了一些 PostgreSQL 特有的特性(SERIAL),这确保了每条消息自动接收一个惟一的 ID;TEXT数据类型;以及REFERENCES,它确保reply_to包含有效的消息 id)。清单 26-2 显示了一个更加 MySQL 友好的版本。

CREATE TABLE messages (
    id          INT NOT NULL AUTO_INCREMENT,
    subject     VARCHAR(100) NOT NULL,
    sender      VARCHAR(15) NOT NULL,
    reply_to    INT,
    text        MEDIUMTEXT NOT NULL, PRIMARY KEY(id)
);
Listing 26-2.Creating the Database in MySQL

最后,对于那些使用 SQLite 的人来说,清单 26-3 中有一个模式。

create table messages (
    id          integer primary key autoincrement,
    subject     text not null,
    sender      text not null,
    reply_to    int,
    text        text not null
);
Listing 26-3.Creating the Database in SQLite

我将这些代码片段保持简单(SQL 专家肯定会找到改进它们的方法),因为这一章的重点毕竟是 Python 代码。SQL 语句创建一个包含以下五个字段(列)的新表:

  • id:用于识别单个消息。数据库管理器会自动为每条消息接收一个惟一的 ID,因此您不必担心如何从 Python 代码中分配这些 ID。
  • subject:包含消息主题的字符串。
  • sender:包含发件人姓名或电子邮件地址等内容的字符串。
  • reply_to:如果该报文是对另一报文的回复,该字段包含另一报文的id。(否则,该字段将不包含任何内容。)
  • text:包含消息正文的字符串。

当您创建了这个数据库并对其设置了权限,从而允许您的 web 服务器读取其内容并插入新行时,您就可以开始编写 CGI 代码了。

首次实施

在这个项目中,第一个原型非常有限。这是一个使用数据库功能的脚本,因此您可以感受一下它是如何工作的。一旦确定了这一点,编写其他必要的脚本就不会很难了。从很多方面来说,这只是对第十三章所涵盖内容的一个简短提醒。

代码的 CGI 部分与第二十五章非常相似。如果你还没有读过那一章,你可能想看一看。你还应该确保阅读第十五章中的“CGI 安全风险”一节。

Caution

在本章的 CGI 脚本中,我已经导入并启用了cgitb模块。这对于发现代码中的缺陷非常有用,但是您可能应该在部署软件之前删除对cgitb.enable的调用——您可能不希望普通用户面临完整的cgitb回溯。

首先,您需要知道 Python DB API 是如何工作的。如果你还没有读过第十三章,你现在至少应该浏览一下。如果您想直接点击,这里是核心功能(用您的数据库模块的名称替换db—例如,psycopgMySQLdb):

  • conn = db.connect('user=foo password=bar dbname=baz'):以用户foo的身份用密码bar连接到名为baz的数据库,并将返回的连接对象分配给conn。(注意connect的参数是一个字符串。)

Caution

在这个项目中,我假设您有一台运行数据库和 web 服务器的专用机器。应该只允许给定用户(foo)从该机器进行连接,以避免不必要的访问。因此没有必要使用密码,但是您的数据库可能需要您设置一个密码。如果你想公开这个论坛,你应该确保你学习了更多关于适当的安全措施,因为这个示例项目是不安全的!

  • curs = conn.cursor():从连接对象中获取光标对象。游标用于实际执行 SQL 语句并获取结果。
  • conn.commit():提交自上次提交以来由 SQL 语句引起的更改。
  • conn.close():关闭连接。
  • curs.execute(sql_string):执行一条 SQL 语句。
  • curs.fetchone():获取一个结果行作为一个序列——例如,一个元组。
  • curs.dictfetchone():获取一个结果行作为字典。(这不是标准的一部分,因此并非在所有模块中都可用。)
  • curs.fetchall():获取所有结果行,作为一个序列序列—例如,一个元组列表。
  • curs.dictfetchall():获取所有结果行作为字典序列(例如,一个列表)。(这不是标准的一部分,因此并非在所有模块中都可用。)

下面是一个简单的测试(假设psycopg)—检索数据库中的所有消息(该数据库目前为空,因此您不会得到任何消息):

>>> import psycopg2
>>> conn = psycopg2.connect('user=foo password=bar dbname=baz')
>>> curs = conn.cursor()
>>> curs.execute('SELECT * FROM messages')
>>> curs.fetchall()
[]

因为您还没有实现 web 界面,所以如果您想测试数据库,您必须手动输入消息。你可以通过一个管理工具(比如 MySQL 的mysql或者 PostgreSQL 的psql)来实现,或者你可以在数据库模块中使用 Python 解释器。

下面是一段有用的代码,您可以将其用于测试目的:

#!/usr/bin/env python
# addmessage.py
import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz)
curs = conn.cursor()

reply_to = input('Reply to: ')
subject = input('Subject: ')
sender = input('Sender: ')
text = input('Text: ')

if reply_to:
    query = """
    INSERT INTO messages(reply_to, sender, subject, text)
    VALUES({}, '{}', '{}', '{}')""".format(reply_to, sender, subject, text)
else:
    query = """
     INSERT INTO messages(sender, subject, text)
     VALUES('{}', '{}', '{}')""".format(sender, subject, text)

curs.execute(query)
conn.commit()

注意这段代码有点粗糙。它不会为您跟踪 id(您必须确保您作为reply_to输入的内容是有效的 ID,如果有的话),并且它不能正确处理包含单引号的文本(这可能会有问题,因为单引号在 SQL 中被用作字符串分隔符)。当然,这些问题将在最终系统中处理。

尝试添加一些消息,并在交互式 Python 提示符下检查数据库。如果一切正常,那么是时候编写一个访问数据库的 CGI 脚本了。

既然你已经弄清楚了处理数据库的代码,并且可以从第二十五章中抓取一些现成的 CGI 代码,那么编写一个查看消息主题的脚本(论坛“主页”的简单版本)应该不会太难。您必须进行标准的 CGI 设置(在本例中,主要是打印Content-type字符串),进行标准的数据库设置(获得一个连接和一个游标),执行一个简单的 SQL select命令来获得所有的消息,然后用curs.fetchallcurs.dictfetchall检索结果行。

清单 26-4 展示了一个完成这些事情的脚本。清单中唯一真正新的东西是格式化代码,它用于获得线程化的外观,回复显示在它们所回复的消息的下方和右侧。

它基本上是这样工作的:

  1. 对于每条消息,获取reply_to字段。如果是None(不是回复),将该消息添加到顶级消息列表中。否则,将该消息附加到保存在children[parent_id]的儿童列表中。
  2. 对于每个顶级消息,调用formatformat函数打印消息的主题。同样,如果消息有孩子,它打开一个blockquote元素(HTML),为每个孩子调用format(递归),并结束blockquote元素。

如果你在你的网络浏览器中打开这个脚本(参见第十五章关于如何运行 CGI 脚本的信息),你应该会看到你添加的所有消息(或者它们的主题)的线程视图。

要了解公告板的样子,请参见本章后面的图 26-1 。

A326949_3_En_26_Fig1_HTML.jpg

图 26-1。

The main page Note

如果你正在使用 SQLite,你不能使用dictfetchall,如清单 26-4 所示。行rows = curs.dictfetchall()可以替换为以下代码片段:

names = [d[0] for d in curs.description]
rows = [dict(zip(names, row)) for row in curs.fetchall()]

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

print("""
<html>
  <head>
    <title>The FooBar Bulletin Board</title>
  </head>
  <body>
    <h1>The FooBar Bulletin Board</h1>
    """)

curs.execute('SELECT * FROM messages')
rows = curs.dictfetchall()

toplevel = []
children = {}

for row in rows:
    parent_id = row['reply_to']
    if parent_id is None:
        toplevel.append(row)
    else:
        children.setdefault(parent_id, []).append(row)
    def format(row):
        print(row['subject'])
        try: kids = children[row['id']]
        except KeyError: pass
        else:
            print('<blockquote>')
            for kid in kids:
                format(kid)
            print('</blockquote>')

    print('<p>')

    for row in toplevel:
        format(row)

    print("""
        </p>
    </body>
    </html>
    """)

Listing 26-4.The Main Bulletin Board (simple_main.cgi

)

Note

如果由于某种原因,你不能让程序工作,那可能是你没有正确设置你的数据库。请查阅数据库文档,了解让给定用户连接和修改数据库需要做些什么。例如,您可能需要明确列出连接机器的 IP 地址。

第二次实施

第一个实现非常有限,因为它甚至不允许用户发布消息。在这一节中,我们将扩展第一个原型中的简单系统,它包含最终版本的基本结构。将添加一些措施来检查所提供的参数(例如检查reply_to是否真的是一个数字,以及是否真的提供了所需的参数),但是您应该注意,使这样的系统健壮且用户友好是一项艰巨的任务。如果你打算使用这个系统(或者,我希望,是你自己的一个改进版本),你应该准备在这些问题上做一些工作。

但是在你考虑提高稳定性之前,你需要一些有用的东西,对吗?那么,你从哪里开始呢?你如何构建系统?

构造 web 程序的一个简单方法(使用 CGI 等技术)是让用户执行的每个动作都有一个脚本。在这个系统中,这意味着以下脚本:

  • main.cgi:显示所有消息(线索)的主题,并带有文章本身的链接。
  • 显示一篇文章,并包含一个可以让你回复的链接。
  • edit.cgi:以可编辑的形式显示一篇文章(带有文本字段和文本区域,就像第二十五章一样)。它的提交按钮链接到保存脚本。
  • save.cgi:接收关于文章的信息(从edit.cgi),并通过在数据库表中插入新行来保存它。

让我们分别处理这些。

编写主脚本

main.cgi脚本与第一个原型中的simple_main.cgi脚本非常相似。主要区别是增加了链接。每个主题将是一个给定消息的链接(到view.cgi),在页面底部,您将添加一个允许用户发布新消息的链接(到edit.cgi的链接)。

看看清单 26-5 中的代码。包含每篇文章链接的行(format函数的一部分)如下所示:

print('<p><a href="view.cgi?id={id}i">{subject}</a></p>'.format(row))

基本上,它创建了一个到view.cgi?id=someid的链接,其中someid是给定行的id。这个语法(问号和key=val)只是向 CGI 脚本传递参数的一种方式。这意味着如果用户点击这个链接,他们将被带到正确设置了id参数的view.cgi。“发布消息”链接只是一个到edit.cgi的链接。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

print("""
<html>
  <head>
     <title>The FooBar Bulletin Board</title>
  </head>
     <body>
       <h1>The FooBar Bulletin Board</h1>
       """)

curs.execute('SELECT * FROM messages')
rows = curs.dictfetchall()

toplevel = []
children = {}

for row in rows:
    parent_id = row['reply_to']
    if parent_id is None:
        toplevel.append(row)
    else:
        children.setdefault(parent_id, []).append(row)

def format(row):
    print('<p><a href="view.cgi?id={id}i">{subject}</a></p>'.format(row))
    try: kids = children[row['id']]
    except KeyError: pass
    else:
        print('<blockquote>')
        for kid in kids:
            format(kid)
        print('</blockquote>')
    print('<p>')

for row in toplevel:

    format(row)

print("""
     </p>
     <hr />
     <p><a href="edit.cgi">Post message</a></p>
  </body>
</html>
""")

Listing 26-5.The Main Bulletin Board (main.cgi)

所以,我们来看看view.cgi是如何处理id参数的。

编写视图脚本

view.cgi脚本使用提供的 CGI 参数id从数据库中检索一条消息。然后,它用结果值格式化一个简单的 HTML 页面。这个页面还包含一个返回到主页(main.cgi)的链接,更有趣的是,还包含一个到edit.cgi的链接,但是这次将reply_to参数设置为id,以确保新消息是对当前消息的回复。view.cgi的代码见清单 26-6 。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

import cgi, sys
form = cgi.FieldStorage()
id = form.getvalue('id')

print("""
<html>
  <head>
    <title>View Message</title>
  </head>
  <body>
    <h1>View Message</h1>
    """)

try: id = int(id)
except:
     print('Invalid message ID')
     sys.exit()

curs.execute('SELECT * FROM messages WHERE id = %s', (format(id),))
rows = curs.dictfetchall()

if not rows:
     print('Unknown message ID')

     sys.exit()

row = rows[0]

print("""
     <p><b>Subject:</b> {subject}<br />
     <b>Sender:</b> {sender}<br />
     <pre>{text}</pre>
     </p>
     <hr />
     <a href='main.cgi'>Back to the main page</a>
     | <a href="edit.cgi?reply_to={id}">Reply</a>
  </body>
</html>
""".format(row))

Listing 26-6.The Message Viewer (view.cgi)

使用 SQL 包本身的拼接机制避免了我们前面的单引号问题——并使代码更安全。

Caution

您应该避免将不受信任的文本直接插入到要用作 SQL 查询的字符串中,因为这样的代码容易受到所谓的 SQL 注入攻击。相反,使用 Python DB API 占位符机制,并为curs.execute提供一个额外的参数元组。更多信息请参见,例如, http://bobby-tables.com

编写编辑脚本

edit.cgi脚本实际上执行双重功能:它用于编辑新消息,也用于编辑回复。区别并不是很大:如果在 CGI 请求中提供了一个reply_to,它会保存在编辑表单中的一个隐藏输入中。隐藏输入用于在 web 表单中临时存储信息。它们不会像文本区域等那样显示给用户,但是它们的值仍然会传递给 CGI 脚本,也就是表单的动作。

此外,默认情况下主题被设置为"Re: parentsubject"(除非主题已经以"Re:"开头——您不想继续添加它们)。下面是处理这些细节的代码片段:

subject = ''
if reply_to is not None:
    print('<input type="hidden" name="reply_to" value="{}"/>'.format(reply_to))
    curs.execute('SELECT subject FROM messages WHERE id = %s', (reply_to,))
    subject = curs.fetchone()[0]
    if not subject.startswith('Re: '):
        subject = 'Re: ' + subject

这样,生成表单的脚本可以将信息传递给最终将处理相同表单的脚本。

清单 26-7 显示了edit.cgi脚本的源代码。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

import cgi, sys
form = cgi.FieldStorage()
reply_to = form.getvalue('reply_to')

print("""
<html>
  <head>
    <title>Compose Message</title>
  </head>
  <body>
    <h1>Compose Message</h1>

    <form action='save.cgi' method='POST'>
    """)

subject = ''
if reply_to is not None:
    print('<input type="hidden" name="reply_to" value="{}"/>'.format(reply_to))
    curs.execute('SELECT subject FROM messages WHERE id = %s', (format(reply_to),))
    subject = curs.fetchone()[0]
    if not subject.startswith('Re: '):
        subject = 'Re: ' + subject

print("""
     <b>Subject:</b><br />
     <input type='text' size='40' name='subject' value='{}' /><br />
     <b>Sender:</b><br />
     <input type='text' size='40' name='sender' /><br />
     <b>Message:</b><br />
     <textarea name='text' cols='40' rows='20'></textarea><br />
     <input type='submit' value='Save'/>
     </form>
     <hr />
     <a href='main.cgi'>Back to the main page</a>'
  </body>
</html>
""".format(subject))

Listing 26-7.The Message Editor (edit.cgi)

编写保存脚本

现在让我们进入最后的剧本。save.cgi脚本将接收关于消息的信息(来自edit.cgi生成的表单),并将它存储在数据库中。这意味着使用 SQL INSERT命令,因为数据库已经被修改,所以必须调用conn.commit,这样当脚本终止时更改不会丢失。

清单 26-8 显示了save.cgi脚本的源代码。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

import cgi, sys
form = cgi.FieldStorage()

sender = form.getvalue('sender')
subject = form.getvalue('subject')
text = form.getvalue('text')
reply_to = form.getvalue('reply_to')

if not (sender and subject and text):
    print('Please supply sender, subject, and text')
    sys.exit()

if reply_to is not None:
    query = ("""
    INSERT INTO messages(reply_to, sender, subject, text)
    VALUES(%s, '%s', '%s', '%s')""", (int(reply_to), sender, subject, text))
else:
    query = ("""
    INSERT INTO messages(sender, subject, text)
    VALUES('%s', '%s', '%s')""", (sender, subject, text))

curs.execute(*query)
conn.commit()

print("""
<html>

<head>
  <title>Message Saved</title>
</head>
<body>
  <h1>Message Saved</h1>
  <hr />
  <a href='main.cgi'>Back to the main page</a>
</body>
</html>s
""")

Listing 26-8.The Save Script (save.cgi)

尝试一下

要测试该系统,首先打开main.cgi。在那里,单击发布消息链接。那应该带你去edit.cgi。在所有字段中输入一些值,然后单击保存链接。

这将把您带到save.cgi,它将显示消息“消息已保存”点击返回主页面链接返回main.cgi。列表现在应该包括您的新消息。

要查看您的邮件,只需点击其主题。您应该使用正确的 ID 前往view.cgi。从那里,试着点击回复链接,这将再次把你带到edit.cgi,但是这次设置了reply_to(在一个隐藏的输入标签中)和一个默认的主题。再次输入一些文本,单击 Save,然后返回主页。它现在应该显示您的回复,显示在原始主题下。(如果没有显示,请尝试重新加载页面。)

主页面如图 26-1 ,消息查看器如图 26-2 ,消息编辑器如图 26-3 。

A326949_3_En_26_Fig3_HTML.jpg

图 26-3。

The message composer

A326949_3_En_26_Fig2_HTML.jpg

图 26-2。

The message viewer

进一步探索

既然您已经有能力开发具有可靠和高效存储的庞大而强大的 web 应用,那么您就有很多事情可以投入进去了。

  • 为你最喜欢的 Monty Python 草图数据库做一个 web 前端怎么样?
  • 如果你对改进本章的系统感兴趣,你应该考虑抽象。创建一个具有打印标准页眉和打印标准页脚功能的实用模块如何?这样,您就不需要在每个脚本中编写相同的 HTML 内容。此外,添加一个具有某种密码处理功能的用户数据库或者抽象出用于创建连接的代码可能会很有用。
  • 如果您想要一个不需要专用服务器的存储解决方案,您可以使用 SQLite(在第十三章中使用),或者一些非 SQL 解决方案,如 MongoDB ( https://mongodb.com ),甚至更技术性的文件格式,如 HDF5 ( http://h5py.org )。

什么现在?

如果你认为编写自己的论坛软件很酷,那么编写自己的点对点文件共享程序怎么样,比如 BitTorrent?在下一个项目中,这正是你要做的。好消息是,由于远程过程调用的奇迹,这将比您迄今为止所做的大多数网络编程都要容易。

二十七、项目 8:使用 XML-RPC 共享文件

本章的项目是一个简单的文件共享应用。你可能对文件共享的概念很熟悉,比如著名的 Napster(不再以其原始形式下载)、Gnutella(参见 http://www.gnutellaforums.com 关于可用客户端的讨论)、BitTorrent(可从 http://www.bittorrent.com 获得)以及许多其他应用。我们将要写的东西在很多方面都与这些相似,尽管要简单得多。

我们将使用的主要技术是 XML-RPC。如第十五章所述,这是一个远程调用程序(函数)的协议,可能通过网络。如果你愿意,你可以很容易地使用普通套接字编程(可能采用第 14 和 24 章中描述的一些技术)来实现这个项目的功能。这甚至可能给你带来更好的性能,因为 XML-RPC 协议确实有一定的开销。然而,XML-RPC 非常易于使用,很可能会大大简化您的代码。

有什么问题?

我们想创建一个点对点的文件共享程序。文件共享基本上意味着在不同机器上运行的程序之间交换文件(从文本文件到声音或视频剪辑的一切)。对等是一个术语,描述计算机程序之间的一种交互,这种交互与常见的客户端-服务器交互(客户端可以连接到服务器,但服务器不能连接到客户端)有所不同。在对等交互中,任何对等体都可以连接到任何其他对等体。在这样的(虚拟)对等网络中,没有中央权威(如客户端/服务器架构中的服务器所代表的),这使得网络更加健壮。除非你把大部分同行都关了,否则不会崩溃。

构建对等系统涉及许多问题。在诸如老式 Gnutella 的系统中,一个对等体可以向它的所有邻居(它知道的其他对等体)传播查询,并且它们可以随后进一步传播该查询。任何响应查询的对等点都可以通过对等点链向最初的对等点发送回复。同事们单独工作,并行工作。最近的系统,比如 BitTorrent,使用了更巧妙的技术,比如要求你上传文件才能被允许下载文件。为了简化事情,这个项目的系统将依次联系每个邻居,等待其响应后再继续。这不如 Gnutella 的并行方法高效,但对于您的目的来说已经足够好了。

大多数对等系统都有巧妙的方式来组织它们的结构——也就是说,哪些对等体“挨着”哪些对等体——以及这种结构如何随着对等体的连接和断开而随时间演变。我们将在这个项目中保持非常简单,但事情有待改进。

以下是文件共享程序必须满足的要求:

  • 每个节点必须跟踪一组已知的节点,它可以向这些节点寻求帮助。一个节点必须能够将自己介绍给另一个节点(从而包含在这个集合中)。
  • 必须能够向节点请求文件(通过提供文件名)。如果节点有问题的文件,它应该返回它;否则,它应该依次向它的每个邻居请求同一个文件(而它们可能依次向它们的邻居请求)。如果其中一个节点拥有该文件,则返回该文件。
  • 为了避免循环(A 问 B,B 反过来问 A)和避免过长的邻居问邻居链(A 问 B 问 C)。。询问 Z),当查询一个节点时,必须能够提供一个历史。这个历史只是到目前为止哪些节点参与了查询的列表。通过不询问历史中已经存在的节点,可以避免循环,通过限制历史的长度,可以避免过长的查询链。
  • 必须有某种方法连接到一个节点,并将自己标识为可信方。通过这样做,您应该能够访问不受信任方(例如对等网络中的其他节点)无法访问的功能。该功能可以包括要求节点从网络中的其他对等体下载并存储文件(通过查询)。
  • 您必须有一些用户界面,让您连接到一个节点(作为受信任的一方)并使它下载文件。它应该很容易扩展和替换这个接口。

所有这些可能看起来有点陡峭,但是正如你将看到的,实现它并不困难。你可能会发现,一旦你有了这些,添加功能也不会那么困难。

Caution

正如文档中指出的,Python XML-RPC 模块对于恶意构造的数据是不安全的。尽管该项目将“可信”节点与“不可信”节点分开,但这不应被视为任何类型的安全保证。在使用系统时,您应该避免连接到不信任的节点。

有用的工具

在这个项目中,我们将使用相当多的标准库模块。

我们将使用的主要模块是xmlrpc.clientxmlrpc.serverxmlrpc.client的用法非常简单。您只需创建一个带有服务器 URL 的ServerProxy对象,就可以立即访问远程过程。使用xmlrpc.server有点复杂,你会在本章的项目中学习到。

对于文件共享程序的接口,我们将使用第二十四章中的朋友cmd。为了获得一些(非常有限的)并行性,我们将使用threading模块,为了提取 URL 的组成部分,我们将使用urllib.parse模块。这些模块将在本章后面解释。

你可能想复习的其他模块有randomstringtimeos.path。更多细节见第十章以及 Python 库参考。

准备

这个项目中使用的库不需要太多准备。如果您有一个相当新的 Python 版本,那么所有必需的库都应该是现成可用的。

你不一定要连接到网络上才能使用这个项目中的软件,但这会让事情变得更有趣。如果您可以访问两台(或多台)连接在一起的独立机器(例如,两台机器都连接到互联网),您可以在每台机器上运行软件,并让它们相互通信(尽管您可能需要对正在运行的任何防火墙规则进行更改)。出于测试目的,也可以在同一台机器上运行多个文件共享节点。

首次实施

在编写第一个Node类的原型(系统中的单个节点或对等点)之前,您需要了解一点来自xmlrpc.serverSimpleXMLRPCServer类是如何工作的。它用一个形式为(servername, port)的元组来实例化。服务器名称是运行服务器的机器的名称。(您可以在这里使用一个空字符串来表示 localhost,即您实际执行程序的机器。)端口号可以是您有权访问的任何端口,通常为 1024 及以上。

在实例化服务器之后,可以用register_instance方法注册一个实现其“远程方法”的实例。或者,您可以用register_function方法注册单个函数。当您准备好运行服务器时(这样它可以响应来自外部的请求),您调用它的方法serve_forever。你可以很容易地尝试这一点。启动两个交互式 Python 解释器。在第一个对话框中,输入以下代码:

>>> from xmlrpc.server import SimpleXMLRPCServer
>>> s = SimpleXMLRPCServer(("", 4242)) # Localhost at port 4242
>>> def twice(x): # Example function
...    return x * 2
...
>>> s.register_function(twice) # Add functionality to the server
>>> s.serve_forever() # Start the server

执行完最后一条语句后,解释器应该看起来像是“挂起”了。实际上,它在等待 RPC 请求。要发出这样的请求,请切换到另一个解释器并执行以下命令:

>>> from xmlrpc.client import ServerProxy # ... or simply Server, if you prefer
>>> s = ServerProxy('http://localhost:4242') # Localhost again...
>>> s.twice(2)
4

令人印象深刻,是吧?特别是考虑到客户端部分(使用xmlrpclib)可以在不同的机器上运行。(在这种情况下,您需要使用服务器的实际名称,而不是简单的 localhost。)如您所见,要访问由服务器实现的远程过程,所需要的就是用正确的 URL 实例化一个ServerProxy。这真的再简单不过了。

实现简单节点

既然我们已经讨论了 XML-RPC 的技术细节,现在是开始编码的时候了。(第一个原型的完整源代码可以在本节末尾的清单 27-1 中找到。)

为了找到从哪里开始,回顾一下本章前面的需求可能是个好主意。我们主要对两件事感兴趣:我们的Node必须持有什么信息(属性),以及它必须能够执行什么动作(方法)?

Node必须至少具有以下属性:

  • 目录名,因此它知道在哪里找到/存储它的文件。
  • 一个“秘密”(或密码),其他人可以使用它来标识自己(作为可信方)。
  • 一组已知的对等点(URL)。
  • 一个 URL,它可以被添加到查询历史或者可能被提供给其他Nodes。(本项目不会实现后者。)

Node构造函数将简单地设置这四个属性。此外,我们需要一个查询Node的方法,一个让它获取和存储文件的方法,以及一个向它引入另一个Node的方法。让我们称这些方法为queryfetchhello。以下是该类的草图,以伪代码形式编写:

class Node:

    def __init__(self, url, dirname, secret):
        self.url = url
        self.dirname = dirname
        self.secret = secret
        self.known = set()

    def query(self, query):
        Look for a file (possibly asking neighbors), and return it as a string

    def fetch(self, query, secret):
        If the secret is correct, perform a regular query and store
        the file. In other words, make the Node find the file and download it.

    def hello(self, other):
        Add the other Node to the known peers

假设已知 URL 的集合叫做knownhello方法非常简单。它只是将other添加到self.known,其中other是唯一的参数(一个 URL)。但是,XML-RPC 要求所有方法都返回值;None不被接受。因此,让我们定义两个表示成功或失败的结果“代码”。

OK = 1
FAIL = 2

那么hello方法可以实现如下:

def hello(self, other):
    self.known.add(other)
    return OK

NodeSimpleXMLRPCServer注册时,就有可能从“外部”调用这个方法。

queryfetch方法有点复杂。让我们从fetch开始,因为它是两者中比较简单的一个。它必须有两个参数:查询和“秘密”,这是必需的,这样您的Node就不会被任何人随意操纵。注意,调用fetch会导致Node下载一个文件。因此,对这个方法的访问应该比query更受限制,因为后者只是传递文件。

如果提供的密码不等于self.secret(启动时提供的密码),fetch简单地返回FAIL。否则,它调用query来获取对应于给定查询的文件(一个文件名)。但是query回报什么呢?当您调用query时,您希望知道查询是否成功,如果成功,您希望返回相关文件的内容。所以,我们把query的返回值定义为 pair (tuple) code, data,其中code不是OK就是FAIL,而data是存储在字符串中的抢手文件(如果code等于OK),否则为任意值(例如空字符串)。

fetch中,检索代码和数据。如果代码是FAIL,那么fetch也简单地返回FAIL。否则,它会打开一个新文件(以写模式),该文件的名称与查询的名称相同,并且位于目录self.dirname中(您使用os.path.join来连接这两个文件)。数据写入文件,文件关闭,返回OK。有关相对简单的实现,请参见本节后面的清单 27-1 。

现在,把注意力转向query。它接收一个查询作为参数,但也应该接受一个历史记录(其中包含不应被查询的 URL,因为它们已经在等待对同一查询的响应)。因为这个历史在第一次调用query时是空的,所以可以使用一个空列表作为默认值。

如果你看一下清单 27-1 中的代码,你会发现它通过创建两个名为_handle_broadcast的实用方法抽象出了query的部分行为。注意,它们的名字以下划线开头,这意味着不能通过 XML-RPC 访问它们。(这是SimpleXMLRPCServer行为的一部分,不是 XML-RPC 本身的一部分。)这很有用,因为这些方法并不意味着向外部提供单独的功能,而是用来构建代码的。

现在,让我们假设_handle负责查询的内部处理(检查文件是否存在于这个特定的Node,获取数据,等等)并且它返回一个代码和一些数据,就像query本身应该做的那样。从清单中可以看到,如果code == OK,那么code, data会立即返回——文件被找到。但是,如果从_handle返回的codeFAIL该怎么办?然后它必须向所有其他已知的Node求助。该过程的第一步是将self.url添加到history

Note

在更新历史记录时,既没有使用+=操作符,也没有使用append列表方法,因为这两种方法都在适当的位置修改列表,并且您不希望修改默认值本身。

如果新的history太长,query返回FAIL(以及一个空字符串)。最大长度被任意设置为 6,并保持在全局常量MAX_HISTORY_LENGTH中。

Why Is Max_History_Length Set to 6?

这个想法是网络中的任何一个对等点都应该能够在最多六步内到达另一个对等点。当然,这取决于网络的结构(哪个同伴认识哪个),但受到“六度分离”假设的支持,该假设适用于人们和他们认识的人。关于这一假设的描述,参见例如维基百科关于六度分离的文章( http://en.wikipedia.org/wiki/Six_degrees_of_separation )。

在你的程序中使用这个数字可能不太科学,但至少看起来是个不错的猜测。另一方面,在一个有许多节点的大型网络中,程序的顺序性质可能会导致大值MAX_HISTORY_LENGTH的糟糕性能,因此如果速度变慢,您可能希望减少它。

如果history不太长,下一步是将查询广播给所有已知的对等点,这是用_broadcast方法完成的。_broadcast方法并不复杂(参见清单 27-1 )。它遍历self.known的一个副本。如果在history中发现一个peer,循环继续到下一个对等点(使用continue语句)。否则,构造一个ServerProxy,并在其上调用query方法。如果查询成功,其返回值将被用作来自_broadcast的返回值。由于网络问题、错误的 URL 或者对等点不支持query方法,可能会出现异常。如果出现这样的异常,对等体的 URL 将从self.known中删除(在包含查询的try语句的except子句中)。最后,如果控制到达了函数的末尾(还没有返回任何东西),那么将返回FAIL和一个空字符串。

Note

您不应该简单地迭代self.known,因为集合可能会在迭代过程中被修改。使用副本更安全。

_start方法创建一个SimpleXMLRPCServer(使用一个小的实用函数get_port,它从一个 URL 中提取端口号),将logRequests设置为 false(您不想保存日志)。然后它用register_instance注册self并调用服务器的serve_forever方法。

最后,模块的main方法从命令行提取一个 URL、一个目录、一个秘密(密码);创建一个Node;并调用它的_start方法。

有关原型的完整代码,请参见清单 27-1 。

from xmlrpc.client import ServerProxy
from os.path import join, isfile
from xmlrpc.server import SimpleXMLRPCServer
from urllib.parse import urlparse
import sys

MAX_HISTORY_LENGTH = 6

OK = 1
FAIL = 2
EMPTY = ''

def get_port(url):
    'Extracts the port from a URL'
    name = urlparse(url)[1]
    parts = name.split(':')
    return int(parts[-1])

class Node:
    """
    A node in a peer-to-peer network.
    """
    def __init__(self, url, dirname, secret):
        self.url = url
        self.dirname = dirname
        self.secret = secret
        self.known = set()

    def query(self, query, history=[]):
        """
        Performs a query for a file, possibly asking other known Nodes for
        help. Returns the file as a string.
        """
        code, data = self._handle(query)
        if code == OK:
            return code, data
        else:
            history = history + [self.url]
            if len(history) >= MAX_HISTORY_LENGTH:
                return FAIL, EMPTY
            return self._broadcast(query, history)

    def hello(self, other):
        """
        Used to introduce the Node to other Nodes.
        """
        self.known.add(other)
        return OK

    def fetch(self, query, secret):
        """
        Used to make the Node find a file and download it.
        """
        if secret != self.secret: return FAIL
        code, data = self.query(query)
        if code == OK:
            f = open(join(self.dirname, query), 'w')

            f.write(data)
            f.close()
            return OK

        else:
            return FAIL

    def _start(self):
        """
        Used internally to start the XML-RPC server.
        """
        s = SimpleXMLRPCServer(("", get_port(self.url)), logRequests=False)
        s.register_instance(self)
        s.serve_forever()

    def _handle(self, query):
        """
        Used internally to handle queries.
        """
        dir = self.dirname
        name = join(dir, query)
        if not isfile(name): return FAIL, EMPTY
        return OK, open(name).read()

    def _broadcast(self, query, history):
        """
        Used internally to broadcast a query to all known Nodes.
        """
        for other in self.known.copy():
            if other in history: continue
            try:
                s = ServerProxy(other)
                code, data = s.query(query, history)
                if code == OK:
                    return code, data
            except:
                self.known.remove(other)
        return FAIL, EMPTY

def main():
    url, directory, secret = sys.argv[1:]
    n = Node(url, directory, secret)
    n._start()

if __name__ == '__main__': main()

Listing 27-1.A Simple Node Implementation (simple_node.py)

现在让我们来看一个简单的例子,看看这个程序是如何使用的。

尝试第一个实现

确保您打开了几个终端(Terminal.app、xterm、DOS window 或等效程序)。假设您想要运行两个对等体(都在同一台机器上)。为他们每个人创建一个目录,比如files1files2。将一个文件(例如,test.txt)放入files2目录。然后,在一个终端中,运行以下命令:

python simple_node.py http://localhost:4242 files1 secret1

在实际的应用中,您会使用完整的机器名而不是localhost,并且您可能会使用比secret1更神秘的密码。

这是你的第一个同伴。现在创建另一个。在不同的终端中,运行以下命令:

python simple_node.py http://localhost:4243 files2 secret2

如您所见,这个对等体提供来自不同目录的文件,使用另一个端口号(4243),并拥有另一个秘密。如果您遵循了这些说明,您应该有两个对等体在运行(每个都在单独的终端窗口中)。让我们启动一个交互式 Python 解释器,并尝试连接到其中一个。

>>> from xmlrpc.client import *
>>> mypeer = ServerProxy('http://localhost:4242') # The first peer
>>> code, data = mypeer.query('test.txt')
>>> code
2

如您所见,第一个对等点在被要求提供文件test.txt时失败了。(返回码2代表失败,还记得吗?)让我们用第二个对等体尝试同样的事情。

>>> otherpeer = ServerProxy('http://localhost:4243') # The second peer
>>> code, data = otherpeer.query('test.txt')
>>> code
1

这一次,查询成功了,因为在第二个对等体的文件目录中找到了文件test.txt。如果您的测试文件没有包含太多的文本,您可以显示data变量的内容,以确保文件的内容已经被正确地传输。

>>> data
'This is a test\n'

目前为止,一切顺利。把第一个同行介绍给第二个怎么样?

>>> mypeer.hello('http://localhost:4243') # Introducing mypeer to otherpeer

现在第一个对等体知道了第二个对等体的 URL,因此可以向它寻求帮助。让我们再次尝试查询第一个对等体。这一次,查询应该会成功。

>>> mypeer.query('test.txt')
[1, 'This is a test\n']

答对了。

现在只剩下一件事需要测试:你能让第一个节点真正从第二个节点下载并存储文件吗?

>>> mypeer.fetch('test.txt', 'secret1')
1

嗯,返回值(1)表示成功。如果你在files1目录中查找,你应该看到文件test.txt奇迹般地出现了。随意启动几个对等机(如果你愿意,可以在不同的机器上)并互相介绍。当你厌倦了游戏,继续下一个实现。

第二次实施

第一个实现有很多缺陷和不足。我不会一一提到(本章末尾的“进一步探索”一节讨论了一些可能的改进),但这里有一些更重要的改进:

  • 如果您试图停止一个Node然后重新启动它,您可能会得到一些关于端口已经被使用的错误消息。
  • 你可能想要一个比交互式 Python 解释器中的xmlrpc.client更加用户友好的界面。
  • 返回代码不方便。如果找不到文件,更自然和 Pythonic 化的解决方案是使用自定义异常。
  • Node不检查它返回的文件是否在文件目录中。通过使用像'../somesecretfile.txt'这样的路径,一个狡猾的黑魔法可能会非法访问你的任何其他文件。

第一个问题很容易解决。您只需将SimpleXMLRPCServerallow_reuse_address属性设置为 true。

SimpleXMLRPCServer.allow_reuse_address = 1

如果不想直接修改这个类,可以创建自己的子类。其他的变化稍微复杂一些,将在下面的章节中讨论。源代码在本章后面的清单 27-2 和 27-3 中显示。(在继续阅读之前,您可能想快速浏览一下这些列表。)

创建客户端界面

客户端接口使用来自cmd模块的Cmd类。关于如何工作的详细信息,请参见第二十四章或 Python 库参考。简单地说,你子类化Cmd来创建一个命令行接口,并为你希望它能够处理的每个命令foo实现一个名为do_foo的方法。该方法将接收命令行的其余部分作为其唯一的参数(作为字符串)。例如,如果您在命令行界面中键入以下内容:

say hello

调用方法do_say时,将字符串'hello'作为唯一的参数。Cmd子类的提示由prompt属性决定。

在你的界面中执行的命令只有fetch(下载文件)和exit(退出程序)。fetch命令简单地调用服务器的fetch方法,如果找不到文件,就打印一条错误消息。exit命令打印一个空行(仅出于美观原因)并调用sys.exit。(EOF命令对应于“文件结束”,当用户在 UNIX 中按 Ctrl+D 时会出现这种情况。)

但是在构造函数中发生了什么呢?嗯,您希望每个客户端都与其自己的对等体相关联。您可以简单地创建一个Node对象并调用它的_start方法,但是在_start方法返回之前,您的Client不能做任何事情,这使得Client完全无用。为了解决这个问题,在一个单独的线程中启动了Node。通常情况下,使用线程会涉及到许多安全措施以及与锁等的同步。然而,因为一个Client只通过 XML-RPC 与它的Node交互,所以你不需要这些。要在一个单独的线程中运行_start方法,你只需要把下面的代码放到你的程序中某个合适的地方:

from threading import Thread
n = Node(url, dirname, self.secret)
t = Thread(target=n._start)
t.start()

Caution

重写这个项目的代码时要小心。当你的Client开始直接与Node对象交互时,或者反之亦然,你可能很容易因为线程而遇到麻烦。在这样做之前,请确保您完全了解线程。

为了确保在开始用 XML-RPC 连接服务器之前,服务器已经完全启动,您将让它先启动,然后用time.sleep等待一会儿。

之后,您将遍历 URL 文件中的所有行,并使用hello方法向它们介绍您的服务器。

你真的不想为想出一个聪明的秘密密码而烦恼。相反,你可以使用实用函数random_string(在清单 27-3 中,这将在本章后面介绍),它生成一个在ClientNode之间共享的随机秘密字符串。

引发异常

不是返回一个指示成功或失败的代码,而是假设成功,并在失败时引发一个异常。在 XML-RPC 中,异常(或错误)由数字标识。对于这个项目,我(任意地)选择了数字 100 和 200 分别表示普通失败(未处理的请求)和请求拒绝(拒绝访问)。

UNHANDLED     = 100
ACCESS_DENIED = 200

class UnhandledQuery(Fault): 

    """
    An exception that represents an unhandled query.
    """
    def __init__(self, message="Couldn't handle the query"):
        super().__init__(UNHANDLED, message)

class AccessDenied(Fault):
    """
    An exception that is raised if a user tries to access a resource for
    which he or she is not authorized.
    """
    def __init__(self, message="Access denied"):
        super().__init__(ACCESS_DENIED, message)

例外是xmlrpc.client.Fault的子类。当它们在服务器中被引发时,它们被传递给具有相同faultCode的客户机。如果一个普通的异常(比如IOException)在服务器中被引发,那么Fault类的一个实例仍然被创建,所以你不能简单地在这里使用任意的异常。

从源代码中可以看出,逻辑基本上还是一样的,但是程序现在使用了异常,而不是使用if语句来检查返回的代码。(因为只能使用Fault对象,所以需要检查faultCodes。当然,如果您没有使用 XML-RPC,您会使用不同的异常类。)

验证文件名

最后要处理的问题是检查给定的文件名是否在给定的目录中。有几种方法可以做到这一点,但是为了保持平台独立(例如,它们可以在 Windows、UNIX 和 macOS 中工作),您应该使用模块os.path

这里采用的简单方法是从目录名和文件名创建一个绝对路径(例如,'/foo/bar/../baz'被转换为'/foo/baz'),目录名与一个空文件名连接(使用os.path.join)以确保它以一个文件分隔符结束(例如'/',然后我们检查绝对文件名是否以绝对目录名开始。如果是,文件实际上在目录中。

第二个实现的完整源代码如清单 27-2 和 27-3 所示。

from xmlrpc.client import ServerProxy, Fault
from os.path import join, abspath, isfile
from xmlrpc.server import SimpleXMLRPCServer
from urllib.parse import urlparse
import sys

SimpleXMLRPCServer.allow_reuse_address = 1

MAX_HISTORY_LENGTH = 6

UNHANDLED     = 100
ACCESS_DENIED = 200

class UnhandledQuery(Fault): 

    """
    An exception that represents an unhandled query.
    """
    def __init__(self, message="Couldn't handle the query"):
        super().__init__(UNHANDLED, message)

class AccessDenied(Fault):
    """
    An exception that is raised if a user tries to access a
    resource for which he or she is not authorized.
    """
    def __init__(self, message="Access denied"):
        super().__init__(ACCESS_DENIED, message)

def inside(dir, name):
    """
    Checks whether a given file name lies within a given directory.
    """
    dir = abspath(dir)
    name = abspath(name)
    return name.startswith(join(dir, ''))

def get_port(url):
    """
    Extracts the port number from a URL.
    """
    name = urlparse(url)[1]
    parts = name.split(':')
    return int(parts[-1])

class Node:
    """
    A node in a peer-to-peer network.
    """
    def __init__(self, url, dirname, secret):
        self.url = url
        self.dirname = dirname
        self.secret = secret
        self.known = set()

    def query(self, query, history=[]):
        """
        Performs a query for a file, possibly asking other known Nodes for
        help. Returns the file as a string.
        """
        try:
            return self._handle(query)
        except UnhandledQuery:
            history = history + [self.url]
            if len(history) >= MAX_HISTORY_LENGTH: raise
            return self._broadcast(query, history)

    def hello(self, other):
        """
        Used to introduce the Node to other Nodes. 

        """
        self.known.add(other)
        return 0

    def fetch(self, query, secret):
        """
        Used to make the Node find a file and download it.
        """
        if secret != self.secret: raise AccessDenied
        result = self.query(query)
        f = open(join(self.dirname, query), 'w')
        f.write(result)
        f.close()
        return 0

    def _start(self):
        """
        Used internally to start the XML-RPC server.
        """
        s = SimpleXMLRPCServer(("", get_port(self.url)), logRequests=False)
        s.register_instance(self)
        s.serve_forever()

    def _handle(self, query):
        """
        Used internally to handle queries.
        """
        dir = self.dirname
        name = join(dir, query)
        if not isfile(name): raise UnhandledQuery
        if not inside(dir, name): raise AccessDenied
        return open(name).read()

    def _broadcast(self, query, history):
        """
        Used internally to broadcast a query to all known Nodes.
        """
        for other in self.known.copy():
            if other in history: continue
            try:
                s = ServerProxy(other)
                return s.query(query, history)
            except Fault as f:
                if f.faultCode == UNHANDLED: pass
                else: self.known.remove(other)
            except:
                self.known.remove(other)
        raise UnhandledQuery

def main():
    url, directory, secret = sys.argv[1:]
    n = Node(url, directory, secret)
    n._start()

if __name__ == '__main__': main()

Listing 27-2.A New Node Implementation (server.py)

from xmlrpc.client import ServerProxy, Fault
from cmd import Cmd
from random import choice
from string import ascii_lowercase
from server import Node, UNHANDLED
from threading import Thread
from time import sleep
import sys

HEAD_START = 0.1 # Seconds
SECRET_LENGTH = 100

def random_string(length):
    """
    Returns a random string of letters with the given length.
    """
    chars = []
    letters = ascii_lowercase[:26]

    while length > 0:
        length -= 1
        chars.append(choice(letters))
    return ''.join(chars)

class Client(Cmd):
    """
    A simple text-based interface to the Node class.
    """

    prompt = '> '

    def __init__(self, url, dirname, urlfile):
        """
        Sets the url, dirname, and urlfile, and starts the Node
        Server in a separate thread.
        """
        Cmd.__init__(self)
        self.secret = random_string(SECRET_LENGTH)
        n = Node(url, dirname, self.secret)
        t = Thread(target=n._start) 

        t.setDaemon(1)
        t.start()
        # Give the server a head start:
        sleep(HEAD_START)
        self.server = ServerProxy(url)
        for line in open(urlfile):
            line = line.strip()
            self.server.hello(line)

    def do_fetch(self, arg):
        "Call the fetch method of the Server."
        try:
            self.server.fetch(arg, self.secret)
        except Fault as f:
            if f.faultCode != UNHANDLED: raise
            print("Couldn't find the file", arg)

    def do_exit(self, arg):
        "Exit the program."
        print()
        sys.exit()

    do_EOF = do_exit # End-Of-File is synonymous with 'exit'

def main():
    urlfile, directory, url = sys.argv[1:]
    client = Client(url, directory, urlfile)
    client.cmdloop()

if __name__ == '__main__': main()

Listing 27-3.A Node Controller Interface (client.py)

尝试第二个实现

让我们看看程序是如何使用的。像这样开始:

python client.py urls.txt directory http://servername.com:4242

文件urls.txt应该每行包含一个 URL——您知道的所有其他对等点的 URL。作为第二个参数给出的目录应该包含您想要共享的文件(并且将是下载新文件的位置)。最后一个参数是对等体的 URL。当您运行此命令时,您应该会得到如下提示:

>

尝试获取不存在的文件:

> fetch fooo
Couldn't find the file fooo

通过启动几个相互了解的节点(要么在同一台机器上使用不同的端口,要么在不同的机器上)(只需将所有的 URL 放在 URL 文件中),您可以像使用第一个原型一样尝试这些节点。当你对此感到厌烦时,继续下一部分。

进一步探索

你或许可以想出几种方法来改进和扩展本章所描述的系统。以下是一些想法:

  • 添加缓存。如果您的节点通过调用query来转发文件,为什么不同时存储文件呢?这样,下次有人要求相同的文件时,您可以更快地做出响应。您也许可以设置缓存的最大大小,删除旧文件,等等。
  • 使用线程或异步服务器(有点困难)。这样,您可以向其他几个节点寻求帮助,而不必等待它们的回复,它们稍后可以通过调用一个reply方法给你回复。
  • 允许更高级的查询,如查询文本文件的内容。
  • 更广泛地使用hello方法。当你发现一个新的同行(通过调用hello),为什么不把它介绍给你认识的所有同行呢?也许你可以想出更聪明的方法来发现新的同伴?
  • 仔细阅读分布式系统的表述性状态转移(REST)哲学。REST 是 XML-RPC 等 web 服务技术的替代技术。(例如参见 http://en.wikipedia.org/wiki/REST )。)
  • 使用xmlrpc.client.Binary包装文件,使非文本文件的传输更加安全。
  • 读取SimpleXMLRPCServer代码。查看libxmlrpc中的DocXMLRPCServer类和 multicall 扩展。

什么现在?

现在你已经有了一个点对点的文件共享系统,如何让它更加用户友好呢?在下一章中,您将学习如何添加一个 GUI 作为当前基于cmd的界面的替代。

二十八、项目 9:文件共享 II——现在有了 GUI!

这是一个相对较短的项目,因为你需要的大部分功能已经写好了——在第二十七章。在这一章中,你会看到在现有的 Python 程序中添加 GUI 是多么容易。

有什么问题?

在这个项目中,我们将用一个 GUI 客户端来扩展在第二十七章中开发的文件共享系统。这将使程序更容易使用,这意味着更多的人可能会选择使用它(当然,多个用户共享文件是该程序的全部意义)。这个项目的第二个目标是展示一个具有足够模块化设计的程序可以非常容易扩展(这是使用面向对象编程的一个理由)。

GUI 客户端应满足以下要求:

  • 它应该允许您输入一个文件名并提交给服务器的fetch方法。
  • 它应该列出服务器文件目录中当前可用的文件。

就这样。因为系统的大部分已经可以工作了,所以 GUI 部分是一个相对简单的扩展。

有用的工具

除了第二十七章中使用的工具之外,您还需要 Tkinter 工具包,它与大多数 Python 安装捆绑在一起。有关 Tkinter 的更多信息,请参见第十二章。如果您想使用另一个 GUI 工具包,请随意。本章中的例子将告诉你如何用你喜欢的工具构建你自己的实现。

准备

在你开始这个项目之前,你应该准备好项目 8(从第章到第二十七章)并且安装一个可用的 GUI 工具包,如前一节所述。除此之外,这个项目不需要任何重要的准备工作。

首次实施

如果您想看一眼第一个实现的完整源代码,您可以在本节后面的清单 28-1 中找到它。许多功能与前一章中的项目非常相似。客户端提供一个接口(fetch方法),用户可以通过该接口访问服务器的功能。让我们回顾一下代码中特定于 GUI 的部分。

第二十七章中的客户端是cmd.Cmd的子类;本章中描述的Client包含tkinter.Frame的子类。虽然你不需要子类化tkinter.Frame(你可以创建一个完全独立的Client类),但这是组织你代码的自然方式。与 GUI 相关的设置放在一个单独的方法中,称为create_widgets,在构造函数中调用。它为文件名创建一个条目,并创建一个获取给定文件的按钮,按钮的动作设置为方法fetch_handler。该事件处理程序与第二十七章中的处理程序do_fetch非常相似。它从self.input(文本字段)中检索查询。然后它在一个try / except语句中调用self.server.fetch

第一个实现的源代码如清单 28-1 所示。

from xmlrpc.client import ServerProxy, Fault
from server import Node, UNHANDLED
from client import random_string
from threading import Thread
from time import sleep
from os import listdir
import sys
import tkinter as tk

HEAD_START = 0.1 # Seconds
SECRET_LENGTH = 100

class Client(tk.Frame):

    def __init__(self, master, url, dirname, urlfile):
        super().__init__(master)
        self.node_setup(url, dirname, urlfile)
        self.pack()
        self.create_widgets()

    def node_setup(self, url, dirname, urlfile):
        self.secret = random_string(SECRET_LENGTH)
        n = Node(url, dirname, self.secret)
        t = Thread(target=n._start)
        t.setDaemon(1)
        t.start()
        # Give the server a head start:
        sleep(HEAD_START)
        self.server = ServerProxy(url)
        for line in open(urlfile):
            line = line.strip()
            self.server.hello(line)

    def create_widgets(self):

        self.input = input = tk.Entry(self)
        input.pack(side='left')

        self.submit = submit = tk.Button(self)
        submit['text'] = "Fetch"
        submit['command'] = self.fetch_handler
        submit.pack()

    def fetch_handler(self):
        query = self.input.get()
        try:
            self.server.fetch(query, self.secret)
        except Fault as f:
            if f.faultCode != UNHANDLED: raise
            print("Couldn't find the file", query)

def main():
    urlfile, directory, url = sys.argv[1:]

    root = tk.Tk()
    root.title("File Sharing Client")

    client = Client(root, url, directory, urlfile)
    client.mainloop()

if __name__ == "__main__": main()

Listing 28-1.A Simple GUI Client (simple_guiclient.py)

除了前面解释的相对简单的代码,GUI 客户端的工作方式就像第二十七章中基于文本的客户端一样。您也可以用同样的方式运行它。要运行这个程序,您需要一个 URL 文件、一个要共享的文件目录和一个您的Node的 URL。下面是一个运行示例:

$ python simple_guiclient.py urlfile.txt files/ http://localhost:8000

注意,文件urlfile.txt必须包含一些其他Node的 URL,程序才能使用。您可以在同一台机器上(使用不同的端口号)启动几个程序进行测试,也可以在不同的机器上运行它们。图 28-1 显示了客户端的图形用户界面。

A326949_3_En_28_Fig1_HTML.jpg

图 28-1。

The simple GUI client

这个实现是可行的,但是它只完成了部分工作。它还应该列出服务器文件目录中可用的文件。为此,必须扩展服务器(Node)本身。

第二次实施

第一个原型非常简单。它做了文件共享系统的工作,但对用户不太友好。如果用户可以看到他们有哪些可用的文件(无论是在程序启动时位于文件目录中,还是随后从另一个Node下载),这将非常有帮助。第二个实现将解决这个文件列表问题。完整的源代码可以在清单 28-2 中找到。

要从Node获取列表,必须添加一个方法。你可以用密码来保护它,就像你对fetch所做的那样,但是公开它可能是有用的,并且它不代表任何真正的安全风险。扩展一个对象真的很容易:你可以通过子类化来实现。您只需用一个额外的方法list构造一个名为ListableNodeNode的子类,该方法使用方法os.listdir,返回一个目录中所有文件的列表。

class ListableNode(Node):

    def list(self):
        return listdir(self.dirname)

为了访问这个服务器方法,方法update_list被添加到客户端。

def update_list(self):
    self.files.Set(self.server.list())

属性self.files指的是一个列表框,它已经被添加到create_widgets方法中。在创建列表框时,在create_widgets中调用update_list方法,并且每次调用fetch_handler时再次调用(因为调用fetch_handler可能会改变文件列表)。

from xmlrpc.client import ServerProxy, Fault
from server import Node, UNHANDLED
from client import random_string
from threading import Thread
from time import sleep
from os import listdir
import sys
import tkinter as tk

HEAD_START = 0.1 # Seconds
SECRET_LENGTH = 100

class ListableNode(Node):

    def list(self):
        return listdir(self.dirname)

class Client(tk.Frame):

    def __init__(self, master, url, dirname, urlfile):
        super().__init__(master)
        self.node_setup(url, dirname, urlfile)
        self.pack()
        self.create_widgets()

    def node_setup(self, url, dirname, urlfile):
        self.secret = random_string(SECRET_LENGTH)
        n = ListableNode(url, dirname, self.secret)
        t = Thread(target=n._start)
        t.setDaemon(1)
        t.start()
        # Give the server a head start:
        sleep(HEAD_START)
        self.server = ServerProxy(url)
        for line in open(urlfile):
            line = line.strip()
            self.server.hello(line)

    def create_widgets(self):
        self.input = input = tk.Entry(self)
        input.pack(side='left')

        self.submit = submit = tk.Button(self)
        submit['text'] = "Fetch"
        submit['command'] = self.fetch_handler
        submit.pack()

        self.files = files = tk.Listbox()
        files.pack(side='bottom', expand=True, fill=tk.BOTH)
        self.update_list()

    def fetch_handler(self):
        query = self.input.get()
        try:
            self.server.fetch(query, self.secret)
            self.update_list()
        except Fault as f:
            if f.faultCode != UNHANDLED: raise
            print("Couldn't find the file", query)

    def update_list(self):
        self.files.delete(0, tk.END)
        self.files.insert(tk.END, self.server.list())

def main():
    urlfile, directory, url = sys.argv[1:]

    root = tk.Tk()
    root.title("File Sharing Client")

    client = Client(root, url, directory, urlfile)
    client.mainloop()

if __name__ == '__main__': main()

Listing 28-2.The Finished GUI Client (guiclient.py)

就这样。现在,您有了一个支持 GUI 的对等文件共享程序,可以使用以下命令运行该程序:

$ python guiclient.py urlfile.txt files/ http://localhost:8000

图 28-2 显示了完成的 GUI 客户端。

A326949_3_En_28_Fig2_HTML.jpg

图 28-2。

The finished GUI client

当然,有很多方法可以扩展这个程序。对于一些想法,见下一节。除此之外,就让你的想象力天马行空吧。

进一步探索

第二十七章给出了一些扩展文件共享系统的想法。这里还有一些:

  • 让用户选择所需的文件,而不是键入其名称。
  • 添加一个状态栏,显示“正在下载”或“找不到文件 foo.txt”等消息
  • 想办法让Node们分享他们的“朋友”例如,当一个Node被介绍给另一个Node时,他们每个人都可以把另一个介绍给它已经认识的Node。此外,在一个Node关闭之前,它可能会告诉它当前所有的邻居它知道的所有Node
  • 向 GUI 添加一个已知的Node列表。可以添加新的 URL 并将其保存在 URL 文件中。

什么现在?

您已经编写了一个成熟的支持 GUI 的对等文件共享系统。虽然这听起来很有挑战性,但其实并不难,不是吗?现在是时候面对最后也是最大的挑战了:编写你自己的街机游戏。

二十九、项目 10:自己动手的街机游戏

欢迎来到期末专题。既然您已经体验了 Python 众多功能中的几个,是时候大干一场了。在这一章中,你将学习如何使用 Pygame,一个可以让你用 Python 编写完整的全屏街机游戏的扩展。虽然很容易使用,但 Pygame 非常强大,由几个组件组成,这些组件在 Pygame 文档中有详细描述(可在 Pygame 网站上找到, http://pygame.org )。这个项目向您介绍了一些主要的 Pygame 概念,但是因为这一章只是作为一个起点,所以我跳过了几个有趣的特性,比如声音和视频处理。一旦熟悉了基础知识,我建议您自己研究一下其他特性。你可能还想看看 Will McGugan 和 Harrison Kinsley 的《Python 游戏开发入门》(Apress,2015)或 Paul Craven 的《用 Python 和 Pygame 编程街机游戏》(Apress,2016)。

有什么问题?

那么,如何编写一个电脑游戏呢?基本的设计过程类似于你编写其他程序时使用的过程,但是在你可以开发一个对象模型之前,你需要设计游戏本身。它的特点、背景和目标是什么?

在这里,我将保持事情相当简单,以免混淆基本 Pygame 概念的演示。如果你喜欢的话,可以随意创建一个更复杂的游戏。

我们要创建的游戏是基于著名的巨蟒剧团的小品《对新鲜水果的自卫》在这个小品中,一名军士长(约翰·克立斯饰)正在指导他的士兵如何自卫,以对付挥舞着新鲜水果的袭击者,这些水果包括石榴、糖水芒果、青果和香蕉。防御技术包括使用枪,释放老虎,并在攻击者身上放下 16 吨重的重物。在这个游戏中,我们将扭转局面——玩家控制着一只香蕉,它拼命试图在自卫过程中幸存下来,避免 16 吨重的重物从上面落下。我想一个合适的游戏名字可能是 Squish。

Note

如果你想在阅读本章的时候尝试自己的游戏,请随意。如果您只是想改变游戏的外观和感觉,只需替换图形(一些 GIF 或 PNG 图像)和一些描述性文本。

这个项目的具体目标围绕着游戏设计。游戏应该按照设计的那样运行(香蕉应该是可移动的,16 吨的重量应该从上面落下)。此外,代码应该是模块化的,易于扩展(一如既往)。一个有用的需求可能是游戏状态(比如游戏介绍、各种游戏关卡和“游戏结束”状态)应该是设计的一部分,并且新的状态应该易于添加。

有用的工具

这个项目中唯一需要的新工具是 Pygame,可以从 Pygame 网站( http://pygame.org )下载。要让 Pygame 在 UNIX 中运行,您可能需要安装一些额外的软件,但是这些都记录在 Pygame 安装说明中(也可以从 Pygame 网站上获得)。与大多数 Python 包一样,最简单的选择可能是使用pip简单地安装 Pygame。

Pygame 发行版包含几个模块,其中大部分在这个项目中是不需要的。以下部分描述了您确实需要的模块。(这里只讨论您需要的特定函数或类。)除了下面几节中描述的函数之外,所使用的各种对象(如曲面、组和精灵)还有几个有用的方法,我将在实现一节中讨论这些方法。

皮尤游戏

pygame模块自动导入所有其他 Pygame 模块,所以如果你把import pygame放在程序的顶部,你可以自动访问其他模块,比如pygame.displaypygame.font

pygame模块包含了Surface函数,它返回一个新的表面对象。表面对象只是给定大小的空白图像,可用于绘图和位图传送。blit(调用一个表面对象的blit方法)仅仅意味着将一个表面的内容转移到另一个表面。(blit 这个词来源于技术术语 block transfer,缩写为 BLT。)

功能是任何 Pygame 游戏的核心。它必须在游戏进入主事件循环之前被调用。该功能自动初始化所有其他模块(如fontimage)。

当您想要捕捉特定于 Pygame 的错误时,您需要使用error类。

pygame.locals

pygame.locals模块包含了您可能想在自己的模块范围内使用的名字(变量)。它包含事件类型、按键、视频模式等的名称。它被设计成当你导入所有东西(from pygame.locals import *)时可以安全使用,尽管如果你知道你需要什么,你可能想要更具体一些(例如,from pygame.locals import FULLSCREEN)。

pygame .显示器

pygame.display模块包含处理 Pygame 显示的函数,这些函数可以包含在普通窗口中,也可以占据整个屏幕。在这个项目中,您需要以下功能:

  • flip:更新显示。通常,当您修改当前屏幕时,可以分两步完成。首先,对从get_surface函数返回的表面对象执行所有必要的修改,然后调用pygame.display.flip来更新显示以反映您的更改。
  • update:当您只想更新屏幕的一部分时,代替flip。它可以与从RenderUpdates类的draw方法返回的矩形列表(在即将到来的pygame.sprite模块的讨论中描述)一起用作它的唯一参数。
  • set_mode:设置显示尺寸和显示类型。有几种可能的变化,但是这里您将限制自己使用FULLSCREEN版本和默认的“在窗口中显示”版本。
  • set_caption:设置 Pygame 程序的标题。当你在一个窗口(相对于全屏)中运行游戏时,set_caption功能非常有用,因为标题被用作窗口标题。
  • get_surface:在调用pygame.display.flippygame.display.blit之前,返回一个你可以在上面绘制图形的表面对象。这个项目中唯一用于绘图的表面方法是blit,它将一个表面对象中的图形转移到另一个给定位置的表面对象上。(此外,Group对象的draw方法将用于在显示面上绘制Sprite对象。)

皮游戏字体

pygame.font模块包含了Font函数。字体对象用于表示不同的字体。它们可以用来将文本呈现为图像,然后在 Pygame 中用作普通图形。

皮格,雪碧

pygame.sprite模块包含两个非常重要的类:SpriteGroup

Sprite类是所有可见游戏对象的基类——在这个项目中,是香蕉和 16 吨重的物体。为了实现你自己的游戏对象,你子类化Sprite,覆盖它的构造函数来设置它的imagerect属性(它们决定了Sprite的外观和放置位置),并覆盖它的update方法,每当 sprite 可能需要更新时就会调用这个方法。

Group类(及其子类)的实例被用作Sprite的容器。一般来说,使用组是一件好事。在简单的游戏中(比如在这个项目中),只需创建一个名为spritesallsprites或类似的群组,并将你所有的Sprite加入其中。当您调用Group对象的update方法时,您所有Sprite对象的update方法将被自动调用。此外,Group对象的clear方法用于擦除它包含的所有Sprite对象(使用回调进行擦除),而draw方法可用于绘制所有的Sprite对象。

在这个项目中,您将使用GroupRenderUpdates子类,它的draw方法返回一个受影响的矩形列表。然后,这些可以被传递到pygame.display.update以仅更新显示器中需要更新的部分。这可能会大大提高游戏的性能。

pygame 鼠标

在 Squish 中,您将使用pygame.mouse模块做两件事:隐藏鼠标光标和获取鼠标位置。你用pygame.mouse.set_visible(False)隐藏鼠标,用pygame.mouse.get_pos()得到位置。

pygame.event

pygame.event模块跟踪各种事件,比如鼠标点击、鼠标运动、按键被按下或释放等等。要获得最近事件的列表,使用函数pygame.event.get

Note

如果仅仅依靠pygame.mouse.get_pos返回的鼠标位置等状态信息,就不需要使用pygame.event.get。然而,你需要保持 Pygame 的更新(“同步”),这可以通过定期调用函数pygame.event.pump来实现。

游戏,图像

pygame.image模块用于处理以 GIF、PNG、JPEG 和其他几种文件格式存储的图像。在这个项目中,您只需要load函数,它读取一个图像文件并创建一个包含该图像的表面对象。

准备

现在你已经知道了一些不同的 Pygame 模块是做什么的,是时候开始破解第一个原型游戏了。但是,在启动原型并运行之前,您需要做一些准备工作。首先你要确定你安装了 Pygame,包括imagefont模块。(您可能希望在交互式 Python 解释器中导入这两者,以确保它们可用。)

你还需要几张图片。如果你想坚持这一章提出的游戏主题,你需要一张描绘 16 吨重的图片和一张描绘香蕉的图片,这两张图片都显示在图 29-1 中。它们的确切大小并不重要,但您可能希望它们保持在 100 × 100 到 200 × 200 像素的范围内。这两个图像应该以通用的图像文件格式提供,比如 GIF、PNG 或 JPEG。

A326949_3_En_29_Fig1_HTML.jpg

图 29-1。

The weight and banana graphics used in my version of the game Note

你可能还需要一个单独的启动画面,第一个欢迎游戏用户的画面。在这个项目中,我也简单地使用了重量符号。

首次实施

当您使用 Pygame 这样的新工具时,保持第一个原型尽可能简单并专注于学习新工具的基础知识,而不是程序本身的复杂性,通常会有所回报。让我们将 Squish 的第一个版本限制为一个 16 吨重的重物从上面落下的动画。为此所需的步骤如下:

  1. 使用pygame.initpygame.display.set_modepygame.mouse.set_visible初始化 Pygame。用pygame.display.get_surface得到屏幕表面。用纯白填充屏幕表面(用fill方法)并调用pygame.display.flip显示这一变化。
  2. 加载重量图像。
  3. 使用图像创建一个自定义Weight类的实例(一个Sprite的子类)。将该对象添加到一个名为spritesRenderUpdates组中。(这在处理多个精灵的时候会特别有用。)
  4. pygame.event.get获取所有最近的事件。依次检查所有事件。如果发现类型为QUIT的事件,或者如果发现代表退出键(K_ESCAPE)的类型为KEYDOWN的事件,退出程序。(事件类型和键保存在事件对象的属性typekey中。可以从pygame.locals模块导入QUITKEYDOWNK_ESCAPE等常量。)
  5. 调用sprites组的clearupdate方法。clear方法使用回调来清除所有精灵(在本例中是权重),update方法调用Weight实例的update方法。(后一种方法必须自己实现。)
  6. 以屏幕表面为参数调用sprites.draw,在当前位置绘制Weight sprite。(每次调用update时,这个位置都会改变。)
  7. 用从sprites.draw返回的矩形列表调用pygame.display.update,只在正确的地方更新显示。(如果你不需要这个性能,你可以在这里使用pygame.display.flip来更新整个显示。)
  8. 重复步骤 4 到 7。

参见清单 29-1 中实现这些步骤的代码。如果用户退出游戏,例如关闭窗口,就会发生QUIT事件。

import sys, pygame
from pygame.locals import *
from random import randrange

class Weight(pygame.sprite.Sprite):

    def __init__(self, speed):
        pygame.sprite.Sprite.__init__(self)
        self.speed = speed
        # image and rect used when drawing sprite:
        self.image = weight_image
        self.rect = self.image.get_rect()
        self.reset()

    def reset(self):
        """
        Move the weight to a random position at the top of the screen.
        """
        self.rect.top = -self.rect.height
        self.rect.centerx = randrange(screen_size[0])

    def update(self):
        """
        Update the weight for display in the next frame.
        """
        self.rect.top += self.speed

        if self.rect.top > screen_size[1]:
            self.reset()

# Initialize things
pygame.init()
screen_size = 800, 600
pygame.display.set_mode(screen_size, FULLSCREEN)
pygame.mouse.set_visible(0)

# Load the weight image
weight_image = pygame.image.load('weight.png')
weight_image = weight_image.convert() # ... to match the display

# You might want a different speed, of courase
speed = 5

# Create a sprite group and add a Weight
sprites = pygame.sprite.RenderUpdates()
sprites.add(Weight(speed))

# Get the screen surface and fill it
screen = pygame.display.get_surface()
bg = (255, 255, 255) # White
screen.fill(bg)
pygame.display.flip()

# Used to erase the sprites:
def clear_callback(surf, rect):
    surf.fill(bg, rect)

while True:
    # Check for quit events:
    for event in pygame.event.get():
        if event.type == QUIT:
            sys.exit()
        if event.type == KEYDOWN and event.key == K_ESCAPE:
            sys.exit()
    # Erase previous positions:
    sprites.clear(screen, clear_callback)
    # Update all sprites:
    sprites.update()
    # Draw all sprites:
    updates = sprites.draw(screen)
    # Update the necessary parts of the display:
    pygame.display.update(updates)

Listing 29-1.A Simple “Falling Weights” Animation (weights.py)

您可以使用以下命令运行该程序:

$ python weights.py

在执行这个命令时,应该确保weights.pyweight.png(权重图像)都在当前目录中。图 29-2 显示了结果的截图。

A326949_3_En_29_Fig2_HTML.jpg

图 29-2。

A simple animation of falling weights

大部分代码应该是不言自明的。然而,有几点需要解释一下:

  • 所有的 sprite 对象都应该有两个名为imagerect的属性。前者应该包含一个 surface 对象(一个图像),后者应该包含一个 rectangle 对象(只需用self.image.get_rect()初始化即可)。这两个属性将在绘制精灵时使用。通过修改self.rect,可以四处移动精灵。
  • 表面对象有一个叫做convert的方法,可以用来创建一个不同颜色模型的副本。您不需要担心细节,但是使用没有任何参数的convert会创建一个为当前显示定制的表面,并且显示它会尽可能快。
  • 颜色通过 RGB 三元组(红绿蓝,每个值为 0-255)来指定,因此元组(255, 255, 255)表示白色。

通过为矩形的属性(topbottomleftrighttoplefttoprightbottomleftbottomrightsizewidthheightcentercenterxcenterymidleftmidrightmidtopmidbottom)赋值,或者调用inflatemove等方法,可以修改矩形(例如本例中的self.rect)。(这些都在 http://pygame.org/docs/ref/rect.html 的 Pygame 文档中有描述。)

既然 Pygame 的技术细节已经到位,是时候扩展和重构游戏逻辑了。

第二次实施

在这一节中,我没有一步一步地向您介绍设计和实现,而是向源代码添加了大量的注释和文档字符串,如清单 29-2 到 29-4 所示。您可以检查源(“使用源”,还记得吗?)来看看它是如何工作的,但这里有一个要点的简短纲要(和一些不太直观的细节):

  • 游戏由五个文件组成:config.py,包含各种配置变量;objects.py,包含游戏对象的实现;squish.py,包含主Game类和各种游戏状态类;还有weight.pngbanana.png,游戏中用到的两个形象。
  • rectangle 方法clamp确保一个矩形被放置在另一个矩形内,如果必要的话移动它。这用于确保香蕉不会移出屏幕。
  • rectangle 方法inflate在水平和垂直方向上按给定的像素数调整矩形的大小。这用于缩小香蕉边界,以便在记录点击(或“挤压”)之前,允许香蕉和权重之间有一些重叠。
  • 游戏本身由一个游戏对象和各种游戏状态组成。游戏对象一次只有一个状态,状态负责处理事件并在屏幕上显示自己。一个状态也可以告诉游戏切换到另一个状态。(Level状态可以例如告诉游戏切换到GameOver状态。)

就这样。您可以通过执行squish.py文件来运行游戏,如下所示:

$ python squish.py

您应该确保其他文件在同一目录中。在 Windows 中,您可以简单地双击squish.py文件。

# Configuration file for Squish
# -----------------------------

# Feel free to modify the configuration variables below to taste.
# If the game is too fast or too slow, try to modify the speed
# variables.

# Change these to use other images in the game:
banana_image = 'banana.png'
weight_image = 'weight.png'
splash_image = 'weight.png'

# Change these to affect the general appearance:
screen_size = 800, 600
background_color = 255, 255, 255
margin = 30
full_screen = 1
font_size = 48

# These affect the behavior of the game:
drop_speed = 1
banana_speed = 10
speed_increase = 1
weights_per_level = 10
banana_pad_top = 40
banana_pad_side = 20

Listing 29-2.The Squish Configuration File (config.py)

import pygame, config, os
from random import randrange

"This module contains the game objects of the Squish game."

class SquishSprite(pygame.sprite.Sprite):

    """
    Generic superclass for all sprites in Squish. The constructor
    takes care of loading an image, setting up the sprite rect, and
    the area within which it is allowed to move. That area is governed
    by the screen size and the margin.
    """

    def __init__(self, image):
        super().__init__()
        self.image = pygame.image.load(image).convert()
        self.rect = self.image.get_rect()
        screen = pygame.display.get_surface()
        shrink = -config.margin * 2
        self.area = screen.get_rect().inflate(shrink, shrink)

class Weight(SquishSprite):

    """
    A falling weight. It uses the SquishSprite constructor to set up
    its weight image, and will fall with a speed given as a parameter
    to its constructor.
    """

    def __init__(self, speed):
        super().__init__(config.weight_image)
        self.speed = speed
        self.reset()

    def reset(self):
        """
        Move the weight to the top of the screen (just out of sight)
        and place it at a random horizontal position.
        """
        x = randrange(self.area.left, self.area.right)
        self.rect.midbottom = x, 0

    def update(self):
        """
        Move the weight vertically (downwards) a distance
        corresponding to its speed. Also set the landed attribute
        according to whether it has reached the bottom of the screen.
        """
        self.rect.top += self.speed
        self.landed = self.rect.top >= self.area.bottom

class Banana(SquishSprite):

    """
    A desperate banana. It uses the SquishSprite constructor to set up
    its banana image, and will stay near the bottom of the screen,
    with its horizontal position governed by the current mouse
    position (within certain limits).
    """

    def __init__(self):
        super().__init__(config.banana_image)
        self.rect.bottom = self.area.bottom
        # These paddings represent parts of the image where there is
        # no banana. If a weight moves into these areas, it doesn't
        # constitute a hit (or, rather, a squish):
        self.pad_top = config.banana_pad_top
        self.pad_side = config.banana_pad_side

    def update(self):
        """
        Set the Banana's center x-coordinate to the current mouse
        x-coordinate, and then use the rect method clamp to ensure
        that the Banana stays within its allowed range of motion.
        """
        self.rect.centerx = pygame.mouse.get_pos()[0]
        self.rect = self.rect.clamp(self.area)

    def touches(self, other):

        """
        Determines whether the banana touches another sprite (e.g., a
        Weight). Instead of just using the rect method colliderect, a
        new rectangle is first calculated (using the rect method
        inflate with the side and top paddings) that does not include
        the 'empty' areas on the top and sides of the banana.
        """
        # Deflate the bounds with the proper padding:
        bounds = self.rect.inflate(-self.pad_side, -self.pad_top)
        # Move the bounds so they are placed at the bottom of the Banana:
        bounds.bottom = self.rect.bottom
        # Check whether the bounds intersect with the other object's rect:
        return bounds.colliderect(other.rect)

Listing 29-3.The Squish Game Objects

(objects.py)

import os, sys, pygame
from pygame.locals import *
import objects, config

"This module contains the main game logic of the Squish game."

class State:

    """
    A generic game state class that can handle events and display
    itself on a given surface.
    """

    def handle(self, event):
        """
        Default event handling only deals with quitting.
        """
        if event.type == QUIT:
            sys.exit()
        if event.type == KEYDOWN and event.key == K_ESCAPE:
            sys.exit()

    def first_display(self, screen):
        """
        Used to display the State for the first time. Fills the screen
        with the background color.
        """
        screen.fill(config.background_color)
        # Remember to call flip, to make the changes visible:
        pygame.display.flip()

    def display(self, screen):
        """
        Used to display the State after it has already been displayed
        once. The default behavior is to do nothing.
        """
        pass

class Level(State):
    """
    A game level. Takes care of counting how many weights have been
    dropped, moving the sprites around, and other tasks relating to
    game logic.
    """

    def __init__(self, number=1):
        self.number = number
        # How many weights remain to dodge in this level?
        self.remaining = config.weights_per_level

        speed = config.drop_speed
        # One speed_increase added for each level above 1:
        speed += (self.number-1) * config.speed_increase
        # Create the weight and banana:
        self.weight = objects.Weight(speed)
        self.banana = objects.Banana()
        both = self.weight, self.banana # This could contain more sprites...
        self.sprites = pygame.sprite.RenderUpdates(both)

    def update(self, game):
        "Updates the game state from the previous frame."
        # Update all sprites:
        self.sprites.update()
        # If the banana touches the weight, tell the game to switch to
        # a GameOver state:
        if self.banana.touches(self.weight):
            game.next_state = GameOver()
        # Otherwise, if the weight has landed, reset it. If all the
        # weights of this level have been dodged, tell the game to
        # switch to a LevelCleared state:
        elif self.weight.landed:
            self.weight.reset()
            self.remaining -= 1
            if self.remaining == 0:
                game.next_state = LevelCleared(self.number)

    def display(self, screen):

        """
        Displays the state after the first display (which simply wipes
        the screen). As opposed to firstDisplay, this method uses
        pygame.display.update with a list of rectangles that need to
        be updated, supplied from self.sprites.draw.
        """
        screen.fill(config.background_color)
        updates = self.sprites.draw(screen)
        pygame.display.update(updates)

class Paused(State):
    """
    A simple, paused game state, which may be broken out of by pressing
    either a keyboard key or the mouse button.
    """

    finished = 0 # Has the user ended the pause?
    image = None # Set this to a file name if you want an image
    text = ''    # Set this to some informative text

    def handle(self, event):
        """
        Handles events by delegating to State (which handles quitting
        in general) and by reacting to key presses and mouse
        clicks. If a key is pressed or the mouse is clicked,
        self.finished is set to true.
        """
        State.handle(self, event)
        if event.type in [MOUSEBUTTONDOWN, KEYDOWN]:
            self.finished = 1

    def update(self, game):
        """
        Update the level. If a key has been pressed or the mouse has
        been clicked (i.e., self.finished is true), tell the game to
        move to the state represented by self.next_state() (should be
        implemented by subclasses).
        """
        if self.finished:
            game.next_state = self.next_state()

    def first_display(self, screen):
        """
        The first time the Paused state is displayed, draw the image
        (if any) and render the text.
        """
        # First, clear the screen by filling it with the background color:
        screen.fill(config.background_color)

        # Create a Font object with the default appearance, and specified size:
        font = pygame.font.Font(None, config.font_size)

        # Get the lines of text in self.text, ignoring empty lines at
        # the top or bottom:
        lines = self.text.strip().splitlines()

        # Calculate the height of the text (using font.get_linesize()
        # to get the height of each line of text):
        height = len(lines) * font.get_linesize()

        # Calculate the placement of the text (centered on the screen):
        center, top = screen.get_rect().center
        top -= height // 2

        # If there is an image to display...
        if self.image:
            # load it:
            image = pygame.image.load(self.image).convert()
            # get its rect:
            r = image.get_rect()
            # move the text down by half the image height:
            top += r.height // 2
            # place the image 20 pixels above the text:
            r.midbottom = center, top - 20
            # blit the image to the screen:
            screen.blit(image, r)

        antialias = 1   # Smooth the text
        black = 0, 0, 0 # Render it as black

        # Render all the lines, starting at the calculated top, and
        # move down font.get_linesize() pixels for each line:
        for line in lines:
            text = font.render(line.strip(), antialias, black)
            r = text.get_rect()
            r.midtop = center, top
            screen.blit(text, r)
            top += font.get_linesize()

        # Display all the changes:
        pygame.display.flip()

class Info(Paused):

    """
    A simple paused state that displays some information about the
    game. It is followed by a Level state (the first level).
    """

    next_state = Level
    text = '''
    In this game you are a banana,
    trying to survive a course in
    self-defense against fruit, where the
    participants will "defend" themselves
    against you with a 16 ton weight.'''

class StartUp(Paused):

    """
    A paused state that displays a splash image and a welcome
    message. It is followed by an Info state.
    """

    next_state = Info
    image = config.splash_image
    text = '''
    Welcome to Squish,
    the game of Fruit Self-Defense'''

class LevelCleared(Paused):
    """
    A paused state that informs the user that he or she has cleared a
    given level. It is followed by the next level state.
    """

    def __init__(self, number):
        self.number = number
        self.text = '''Level {} cleared
        Click to start next level'''.format(self.number)

    def next_state(self):
        return Level(self.number + 1)

class GameOver(Paused):

    """
    A state that informs the user that he or she has lost the
    game. It is followed by the first level.
    """

    next_state = Level
    text = '''
    Game Over
    Click to Restart, Esc to Quit'''

class Game:

    """
    A game object that takes care of the main event loop, including
    changing between the different game states.
    """

    def __init__(self, *args):
        # Get the directory where the game and the images are located:
        path = os.path.abspath(args[0])
        dir = os.path.split(path)[0]
        # Move to that directory (so that the image files may be
        # opened later on):
        os.chdir(dir)
        # Start with no state:
        self.state = None
        # Move to StartUp in the first event loop iteration:
        self.next_state = StartUp()

    def run(self):
        """
        This method sets things in motion. It performs some vital
        initialization tasks, and enters the main event loop.
        """
        pygame.init() # This is needed to initialize all the pygame modules

        # Decide whether to display the game in a window or to use the
        # full screen:
        flag = 0                  # Default (window) mode

        if config.full_screen:
            flag = FULLSCREEN     # Full screen mode
        screen_size = config.screen_size
        screen = pygame.display.set_mode(screen_size, flag)

        pygame.display.set_caption('Fruit Self Defense')
        pygame.mouse.set_visible(False)

        # The main loop:
        while True:
            # (1) If nextState has been changed, move to the new state, and
            #     display it (for the first time):
            if self.state != self.next_state:
                self.state = self.next_state
                self.state.first_display(screen)
            # (2) Delegate the event handling to the current state:
            for event in pygame.event.get():
                self.state.handle(event)
            # (3) Update the current state:
            self.state.update(self)
            # (4) Display the current state:
            self.state.display(screen)

if __name__ == '__main__':
    game = Game(*sys.argv)

    game.run()

Listing 29-4.The Main Game Module (squish.py)

游戏的部分截图如图 29-3 到 29-6 所示。

A326949_3_En_29_Fig6_HTML.jpg

图 29-6。

The “game over” screen

A326949_3_En_29_Fig5_HTML.jpg

图 29-5。

The “level cleared” screen

A326949_3_En_29_Fig4_HTML.jpg

图 29-4。

A banana about to be squished

A326949_3_En_29_Fig3_HTML.jpg

图 29-3。

The Squish opening screen

进一步探索

以下是一些改进游戏的方法:

  • 给它加上声音。
  • 记录分数。例如,每躲开一个重量可以值 16 点。留个高分档案怎么样?或者甚至是一个在线高分服务器(使用asyncore或 XML-RPC,分别在第二十四章和第二十七章中讨论)?
  • 让更多的物体同时落下。
  • 颠倒一下逻辑:让玩家尝试被击中,而不是躲避,就像彼得·古德(Peter Goode)的老 Memotech 游戏《抓蛋者》(Egg Catcher)一样,这是 Squish 的主要灵感来源。
  • 给玩家不止一条“命”
  • 创建游戏的独立可执行文件。(详见第十八章。)

关于 Pygame 编程的一个更复杂(也非常有趣)的例子,可以看看 Pygame 维护者 Pete Shinners 的 SolarWolf 游戏( http://www.pygame.org/ shredwheat/solarwolf)。你可以在 Pygame 网站上找到大量的信息和其他几款游戏。如果玩 Pygame 让你迷上了游戏开发,你可能会想去看看像 http://www.gamedev.nethttp://gamedev.stackexchange.com 这样的网站。网络搜索应该会给你很多类似的网站。

什么现在?

嗯,就是这样。你已经完成了最后一个项目。如果你评估你已经完成的事情(假设你已经跟踪了所有的项目),你应该会对自己留下深刻的印象。所展示主题的广度让您领略了 Python 编程世界中等待您的各种可能性。我希望到目前为止您已经享受了这次旅行,并祝您在作为 Python 程序员的继续旅程中好运。