本项目是一个简单的文件共享应用程序。通过Napster(最初形式的版本已不能下载)、Gnutella(有关可用客户端的讨论,请参阅http://www.gnutellaforums.com)、BitTorrent(可从http://www.bittorrent.com下载)等众多著名应用程序,你可能已经熟悉文件共享的概念。本项目将编写的应用程序在很多方面都与它们类似,只是要简单的多。
我们将使用的主要技术是XML-RPC。这是一种远程调用过程(函数)的协议,这种调用可能是通过网络进行的。如果你愿意,可使用普通的套接字编程轻松地实现这个项目的功能。这样做还可以获得更加的性能,因为XML-RPC确实存在一定的开销。然而,XML-RPC使用起来非常容易,还很可能极大的简化代码。
1.问题描述
我们要创建P2P(peer-to-peer)文件共享程序。大致而言,文件共享意味着在运行于不同计算机上的程序之间交换文件(从文本文件到声音或视频剪辑的各种文件)。P2P指的是计算机程序之间的一种交互方式,与常见的客户端-服务器交互(在这种交互中,客户端可链接到服务器,但反过来不行)不太一样。在P2P交互中,任何对等体(peer)都可连接到其它对等体。在这样一个由对等体组成的网络中,不存在中央权威(在客户端/服务器架构中,这样的权威为服务器),这让网络更健壮,因为除非你关闭大部分对等体,否则这样的网络不可能崩溃。
在创建P2P系统的过程中,会遇到很多问题。在诸如Gnutella等较旧的系统中,对等体可能向所有的邻居(它知道的其他对等体)广播查询,而这些对等体可能进一步广播查询。这样,响应查询的对等体都可通过对等体链将应答发回给最初发起查询的对等体。对等体独立而并行的工作。在诸如BitTorrent等较新的系统中,使用了更巧妙的技术,如要求你上传文件后才有权下载文件。出于简化考虑,这个项目的系统将依次与每个邻居联系,等收到响应后再与下一个对等体联系。这种做法的效率与Gnutella采用的并行做法没法比,但就这个系统的目标而言足够了。
大多数P2P系统都采用巧妙的方式来组织其结构(即每个对等体与哪些对等体相邻)以及这种结构随对等体连接或断开的变化方式。在这个项目中,我们将采用非常简单的方式,但留有改进的余地。
这个文件共享程序必须满足的需求如下。
- 每个节点都必须跟踪一组已知的节点,以便能够向这些节点寻求帮助。还必须让节点能够向其他节点介绍自己,从而成为其他节点跟踪的节点集中的一员。
- 节点必须能够通过提供文件名向其他节点请求文件。如果对方有这样的文件,应将其返回,否则应转而向其邻居请求这个文件(而这些邻居可能转而向其邻居请求该文件)。被请求的节点如果有这样的文件,就将其返回。
- 为避免循环(A向B请求,B又反过来向A请求),同时避免形成过长的请求链(A向B请求,B向C请求等,直到向Z请求),向节点查询时必须提供历史记录。这个历史记录其实就是一个列表,其中包含在此之前已查询过的所有节点。通过不向历史记录中已有的节点请求,可避免循环,而通过限制历史记录的长度,可避免查询链过长。
- 必须能够连接到其他节点,并将自己标识为可信任方。通过这样做,节点将能够使用不可信任方(如P2P网络中的其他节点)无法使用的功能。这种功能可能包括请求对方通过查询从网络中的其他节点下载文件并存储。
- 必须提供这样的用户界面:让用户能够作为可信任方连接到其他节点,并让对方下载文件。这种界面应该能很轻松地扩展乃至替换。
要满足这些需求似乎有点难,但你将看到,它们实现起来并不太难。你还可能发现,实现这些功能后,再添加其他功能也不会太难。
警告 正如文档指出的,与XML-RPC相关的Python模块不能防范恶意创建的数据带来的风险。虽然这个项目将节点分为可信任的和不可信任的,但不应将此视为安全保障。在使用这个系统的过程中,千万不要连接到你不信任的节点。
2.有用的工具
在这个项目中,我们将使用很多标准库模块。
使用的主要模块为xmlrpc.client和xmlrpc.server。模块xmlrpc.client的用法非常简单,你只需使用服务器的URL创建一个ServerProxy对象,就能够马上访问远程过程。模块xmlrpc.server使用起来要复杂些,在你完成项目的过程中将看到这一点。
为了实现这个文件共享程序的界面,我们将使用模块cmd。为实现一定(非常有限)的并行性,我们将使用模块threading。为提取URL的组成部分,我们将使用模块urllib.parse。这些模块将在后面介绍。
你可能还需看一下其他模块,包括random、string、time和os.path。
3.准备工作
为使用将用到的库,无需做更多准备工作。如果你使用的Python版本较新,其中应该包含这里要用到的所有库。
要使用这个软件,计算机并非一定要连接到网络,不过连接到网络将更有趣。如果你有多台相连的计算机(如它们都连接到了互联网),就可分别在每台计算机上运行这个软件,从而让它们彼此通信(但你可能需要修改当前正在运行的防火墙规则)。就测试而言,可在同一台计算机上运行多个文件共享节点。
4.初次实现
要编写Node类(系统中的单个节点,即对等体)的第一个原型,必须对模块xmlrpc.server中SimpleXMLRPCServer类的工作原理有些了解。这个类是使用形如(servername, port)的元组来实例化的,其中servername是运行服务器的计算机名称(可将其设置为空字符串来表示localhost,即执行程序的计算机),而port是你能够访问的任何端口,通常为1024或更大的值。
实例化服务器后,可使用方法register_instance注册一个实现了其“远程方法”的实例,也可使用方法register_function注册各个函数。为运行服务器做好准备(让它能够响应外部的请求)后,调用其方法serve_forever。你可轻松地尝试做到这一点。为此,可启动两个交互式Python解释器,在第一个解释器中输入如下代码:
执行最后一条语句后,解释器看起来就像“挂起”了一样,但实际上它是在等待RPC请求。为发出这样的请求,切换到另一个解释器并执行如下代码:
很厉害吧,如果考虑到使用xmlrpclib的客户端可运行在其他计算机上,就尤其如此了。在这种情况下,必须使用服务器计算机名称而不是localhost。如你所见,要访问服务器实现的远程过程,只需使用正确的URL实例化一个ServerProxy。真的不能比这更容易了。
4.1.实现简单的节点
介绍XML-RPC技术后,该着手编码了。
为找到切入点,回顾一下前面介绍的需求是个不错的主意。我们关心的主要有两点:Node必须存储哪些信息(属性);Node必须能够执行哪些操作(方法)。
Node必须至少包含如下属性。
- 目录名:让Node知道哪里去查找文件或将文件存储到哪里。
- 密码:供其他节点来将自己标识为可信任方。
- 一组已知的对等体(URL)。
- URL:可能加入到查询历史记录中或提供给其他节点(这个项目不会以第二种方式使用URL)。
Node的构造函数只是设置这4个属性。除构造函数外,还需要用于查询的方法、获取和存储文件的方法以及向其他节点介绍自己的方法。我们将这些方法分别命名为query、fetch和hello。下面是使用伪代码编写的Node类的骨架:
假设已知对等体集合名为known,方法hello将非常简单,它只需将other添加到self.known中即可,其中other是这个方法的唯一参数(一个URL)。然而XML-RPC要求所有方法都必须返回一个值,而不能返回None。有鉴于此,下面来定义两个指出成功还是失败的“编码”、
OK = 1
FAIL = 2
然后像下面这样实现方法hello:
像SimpleXMLRPCServer注册以后,就可从外面调用这个方法了。
方法query和fetch要棘手些。先来编写fetch,因为它更简单。这个方法必须接受参数query和secret,其中secret是必不可少的,可避免节点被其他节点随便操纵。请注意,调用fetch将导致节点下载一个文件。因此,相比于只是传递文件的方法query,应更严格的限制对这个方法的访问。
如果提供的密码不同于(启动时指定的)self.secret,fetch将直接返回FAIL;否则它将调用query来获取指定的文件。但方法query该返回什么呢?调用query时,你希望能够知道查询是否成功,并在成功时返回指定文件的内容。因此,我们将query的返回值定义为元组(code, data),其中code的可能取值为OK和FAIL,而data是一个字符串。如果code为OK,这个字符串将包含找到的文件的内容;否则为一个随意的值,如空字符串。
方法fetch获取code和data。如果code为FAIL,这个方法也直接返回FAIL,否则就以写入模式打开一个新文件【这个文件的名称由参数query指定,它包含在目录self.dirname中(使用os.path.join将两者合而为一)】,再将data写入文件,然后关闭这个文件并返回OK。
现在来看方法query。它接受参数query,但还应将历史记录作为参数(历史记录包含一系列不应再向其查询的URL,因为它们正在等待该查询的响应)。鉴于刚调用query,历史记录为空,因此可将这个参数的默认值设置为空列表。
我会进一步抽象了方法query,这是通过创建两个名为_handle和_broadcast的工具方法实现的。请注意,这些方法的名称以下划线打头,意味着不能通过XML-RPC来访问它们。(这是SimpleXMLRPCServer的行为,而不是XML-RPC的组成部分。)这很有用,因为这些方法并非要向外部提供独立的功能,而只是用于组织代码。
就现在而言,假设_handle负责查询的内容处理(检查节点是否包含指定的文件,获取数据等),它像query一样返回一个编码和一些数据。如果code为OK(找到了指定的文件),方法_handle将立即返回code和data。然而,如果_handle返回的code为FAIL,那么query该如何办呢?在这种情况下,它必须向其他所有已知的节点寻求帮助。为此,它首先将self.url添加到history中。
注意 更新history时,既没有使用+=运算符,也没有使用列表方法append,因为它们都就地修改列表,而你不想修改参数history的默认值。
如果新的history太长,query将返回FAIL(和一个空字符串)。这里随意的将最大长度设置成了6,并将其存储在全局常量MAX_HISTORY_LENGTH中。
为何将MAX_HISTORY_LENGTH设置为6
这样做基于的理念是,网络中任何对等体最多通过6步就能到达其他任何对等体。当然,这取决于网络的结构(每个对等体都知道哪些对等体),不过也得到了有关人际关系的“六度分离”假设的支持。有关这种假设的描述,请参阅维基百科上讨论六度分离的文章(http://en.wikipedia/wiki/Six_degrees_of_separation)。
在这个程序中使用这样的数字可能不太科学,但至少是不错的估计。在包含大量节点的大型网络中,鉴于这个程序的非并行性质,将MAX_HISTORY_LENGTH设置为较大的值可能导致性能变差。因此,如果速度很慢,可能应该降低这个值。
如果history不太长,就使用方法_broadcast向所有已知的对等体广播查询。方法_broadcast不太复杂。它迭代self.known的副本,如果当前对等体包含在history中,就使用continue语句跳到下一个对等体,否则创建一个ServerProxy对象,并调用其方法query。如果方法query成功,就将其返回值作为_broadcast的返回值。可能会因为网络问题、错误的URL或节点不支持方法query而引发异常,在这种情况下,将把对等体的URL从self.known中删除(这是在包含query调用的try语句的except子句中进行的)。最后,如果正常到达了函数末尾(什么都没有返回),将返回FAIL和一个空字符串。
注意 不应直接迭代self.known本身,因为这个集合在迭代期间可能被修改。使用其副本更安全。
方法_start(使用从URL中提取端口号的小型工具函数get_port)创建一个SimpleXMLRPCServer,并将logRequests设置为False(不存储日志),然后使用register_instance注册self,并调用服务器的方法serve_forever。
最后,这个模块的方法main从命令行提取URL、目录和密码,再创建一个Node对象并调用其方法_start。
这个原型的最终代码如图所示。
下面来看一个有关如何使用这个程序的简单示例。
4.2.尝试使用
确保打开了多个终端(Terminal.app、xterm、DOS窗口或其他终端)。假设你要(在同一台计算机上)运行两个对等体,需为每个对等体分别创建一个目录(如files1和files2),在目录files2中放置一个文件(如test.txt),再在一个终端运行如下命令:
python simple_node.py http://localhost:4242 files1 secret1
实际运行程序时,将使用完整的计算机名称而不是localhost,还可能使用比secret1更复杂的密码。
这就是第一个对等体。接下来,在创建一个对等体。为此,在另一个终端中运行如下命令:
python simple_node.py http://localhost:4243 files2 secret2
如你所见,这个对等体提供位于另一个目录中的文件,并使用不同的端口号(4243)和密码。如果你按前面说的做了,应该有两个不同的对等体在运行(它们位于不同的终端窗口中)。下面来启动交互式Python解释器,并尝试连接到其中的一个对等体。
如你所见,向第一个对等体请求文件test.txt时失败了。(返回编码2表示失败,还记得吗?)下面来尝试向第二个节点请求文件test.txt。
这次查询成功了,因为文件test.txt包含在第二个对等体的文件目录中。如果test.txt包含的文本不多,可显示变量data的内容,以核实正确的传输了文件test.txt的内容。
到目前为止一切顺利。向第二个对灯体介绍第一个对灯体后,结果将如何呢?
现在,第一个对等体知道第二个对等体的URL,可向其寻求帮助了。再次尝试第一个对等体查询,这次查询将成功。
成功了!
现在就剩一项功能没有测试了:可让第一个节点从第二个节点那里下载文件并存储它吗?
返回值(1)表明成功了。如果你查看目录files1,将发现文件test.txt奇迹般地出现在这里。请启动多个对等体(如果你愿意,可在不同的计算机上启动它们),并将每个对等体都介绍给其他所有对等体。等你玩烦了,再来看下一个实现。
关于P2P文件共享基本功能已经实现,明天将写一个控制台的客户端取代目前的交互式Python解释器!今天的文章有不懂的可以加群(群号:822163725,备注:小陈学Python,不备注可是会被拒绝的哦~!)
最后欢迎大家扫码关注