BitTorrent源码分析(4.0.3) .

BT的源代码是使用python写的,这是一种动态类型的语言,所有的对象不需要定义其类型,任何对象可以作为参数传入某个函数中,唯一的要求是当调用该对象的某个方法时,它必须存在。另外这种语言提供了大量的模块,这些模块中很多都能在不同的平台实现其功能,大大得方便了编写跨平台程序。

    在BT的代码中,主要功能都有命令行模式和图形界面模式两种执行方式,但最后它们执行的核心功能的代码都是相同的,区别在于执行这些核心功能时,传递给它们的参数是从命令行和配置文件处获取还是从图形界面中获取。


    在我开始学习时,看的是4.0.3版本的代码。主要有两个主要的执行模块btdownloadgui和btmaketorrentgui,前者是客户端,后者是制造种子文件的工具(从4.0.0版本开始,btmaketorrentgui代替了btcompletedir)。另外,还有个tracker模块也很重要。学习的时候如果喜欢直接切入正题,就可以不看和gui相关的部分,直接看实现核心功能的模块。


    提一下图形界面,BT的图形界面模块用的是gtk,它的详细资料可以在这里找到:http://www.pygtk.org/


    使用gtk编写图形界面的好处是它的跨平台性很好,可以在不同的操作系统上生成风格相近的图形界面。另外在BT中貌似还用了另一个图形界面模块库 (btdownloadcurses),我大概看了一下说明,好像这个curses只能用于某些平台,想了下我主要的学习目的是BT,于是在GUI方面就集中精力攻gtk了,这个curses库就没有去看它。


  我学习BT的过程大概如下:

    看python语言教程熟悉python语言。

    试着看btdownloadgui,发现看着头很大,另外发现很多模块在python网站上的模块参考手册上没有。遂发现了gtk的网站,熟悉了一下使用 gtk编写GUI程序的基本方法后,继续试图看btdownloadgui的图形部分,有些明白,但是还是感觉到有些吃力。

    开始尝试转移一下目标,先看btmaketorrentgui,研究一下种子文件是怎么生成的,如果心里对种子文件的结构有了解再研究下载部分的代码应该能轻松些。这部分比较成功得完成了,学习到了BT的种子文件的结构,还对gtk的GUI程序编写也比较熟悉了。

    接下来看的是tracker部分的代码,看的时候基本上都看完了,知道了一个tracker是如何得与客户端通信。但是对于一些具体的数据结构可能还会存在一些模糊的地方。

    最后回过头来看btdownloadgui的代码,发现终于可以顺利得看下去了。然后将所有看到的结果总结起来,学习到了BT的通信协议。

    今后的部分将把以上说的学习过程具体展开。



0. 程序运行参数的获取

    把这部分单独列举出来,是因为我觉得BT的程序在处理配置参数方面的这部分代码很有参考价值。


    程序的配置参数首先来源于BitTorrent/defaultargs.py。这个模块中包含了一些参数的默认值,由于它们是直接编译进BT的模块中,因此即使其它的配置文件都丢失后,程序还是有一些默认值可以作为参数。defaultargs.py中定义了一个函数get_defaults可以让各个可执行模块得到它们对应的默认值。例如btdownloadgui和btmaketorrentgui得到的默认值是不同的。而且 defaultargs.py中为可执行模块生成的默认属性集合是一个以三元组为元素的列表。每一个三元组代表一条属性(也就是一条程序的配置参数),每个三元组包含了属性的名称,属性的值和该属性的说明。值得注意的是,在后面我们可以看到,属性的说明直接作为帮助信息输出给用户。


    在获取完默认值后,各个可执行模块通常都调用BitTorrent/configfile.py中的函数 parse_configuration_and_args来进一步从可能存在的配置文件和命令行参数中对程序运行的一些参数进行调整。这个函数中首先从配置文件中获取信息,值得一提的是它获取配置文件的方法。BT将在用户的"主目录"下建立一个.bittorrent的目录,并且从这里读取配置文件(如果这个文件不存在就建立一个,下次就存在了)。其中"主目录"的获取方法在BitTorrent/__init__.py中,它的实现方法比较巧妙,利用了python的os和os.path库进行操作,可以针对不同的操作系统使用合适的方法得到这个"主目录"。在windows下,这个目录通常是文档和设置目录下的用户名目录下的"Application Data",而在linux下这个目录就是~。


    在parse_configuration_and_args中,还要调用BitTorrent/parseargs.py模块中的parseargs对命令行中输入的参数进行处理。根据其中的注释,可以看出程序将会把以下格式的参数作为一种控制方面的操作(options)而改变原来的配置,其它的参数都只是简单得堆在args数组中供可执行模块自己去处理。这些格式有--aaa 1型,即以两个-号开头的参数,将和他后面的参数一起,构成一个配置项(aaa=1),-a1型,即以一个-号开头的参数,直接取一个字母作为配置项的属性名,这个参数后面的子字符串作为配置项的属性值(a=1),-a 1型,即以一个-号开头的参数,但是后面只有一个字母,就将下一个参数作为属性值(因此这里也是a=1)。


    经过这些处理后,程序的其它部分就可以很方便得使用配置好的参数了,例如:

    if self.config['xxx']

         .......

    或者

    aaa = self.config['bbb']

    等等。


----------------------------------------------------


1.1 种子文件的编码方式

    BT的作者使用了一种比较简单易懂的编码方式来对设计种子文件。这种编码方式能够很简单得对python中的各种数据类型,如字符串,整数,列表,字典等进行编码。而且对于类型的嵌套,如一个列表中的元素又是一个列表等情况能够进行很好得处理。


    BitTorrent/bencode.py模块负责进行编码解码的工作。函数bencode能够对python的复杂数据类型进行编码。这个函数的意思难道是BT encode?它通过恰当得递归调用自己来完成任务。


    首先他判断要编码的数据的类型,然后根据这个类型调用相应的编码函数encode_func[type(x)],定义了以下类型的编码函数:


    encode_int,负责对IntType和LongType类型进行编码。编码为一个字母"i"加上这个数值的字符串表示再加上一个字母"e"。就是说整数19851122将会被编码成"i19851122e"存在文件中。


    encode_string,负责对StringType类型进行编码。编码方式为字符串的长度加上一个冒号":"再加上字符串本身。例如helloworld将被编码成"10:helloworld"存放在文件中。


    encode_list,负责对ListType和TupleType类型进行编码。编码方式为一个字母"l",然后递归得调用相应的编码函数将列表或者元组的所有元素进行bencode,最后编上一个"e"结束。


    encode_dict,负责对DictType类型进行编码。编码方式为一个字母"d",然后递归得对每个元素进行处理。在DictType中,每个元素都由一个key和value对组成。首先以长度加":"加实际值的方式编码key,因为key通常都是简单值,所以可以这样编码。然后对value进行 bencode,最后加上一个"e"结束。


    通过分析以上的编码函数我们可以看出,复杂的对象被以此种编码方式进行编码后将能够无歧义地被还原出来。而BT的种子文件就是这样一种复杂的对象(字典类型)。知道编码方式后,下次介绍种子文件时,只需要解释这个字典类型包含的每个元素的情况即可,保存成文件和从文件中读取的这个过程就不需要再解释了。



1.2 种子文件的生成

   在知道种子文件采取的编码方式后,我们现在可以来看一个种子文件具体是如何生成的了。在BT中,生成种子文件的可执行模块是 btmaketorrent.py(命令行模式)或者btmaketorrentgui.py(图形界面模式),通过分析,可以知道它们最终都将调用函数 make_meta_files进行种子文件的生成,区别仅仅在于提供给这个函数的参数从何而来。命令行模式下的程序很简单,即直接从命令行下获取参数,GUI部分的程序以后再和下载客户端的图形界面程序一起分析,现在我们先直接切入正题。


    BitTorrent/makemetafile.py模块中提供函数make_meta_files。它的参数意义如下:


    URL:Tracker的URL地址,在BT的协议设计中,还是需要有个服务器作为tracker来协调各个客户端的下载的,tracker部分的程序以后会介绍,现在只需要知道这个URL将要作为一条信息写入到种子文件中即可。


    file:种子文件的来源文件或目录列表(即准备要在BT上共享的资源),注意,这里的列表意思是该列表中的每一项都为其生成一个种子文件,而此列表中的每一项可以是一个文件或者是一个目录。


    flag:一个Event对象,可以用来检查是否用户要求中止程序。程序设计得比较合理,可以在很细的粒度下检查这个Event是否被触发,如果是则中止执行。


    progressfunc:一个回调函数,程序会在恰当的地方调用它,以表示现在的工作进度,在命令行模式下,这个回调函数被指向在控制台上显示进度信息的函数,在GUI模式下,这个回调函数则会影响一个图形界面的进度条。


    filefunc:也是一个回调函数,程序会在恰当的地方调用它,以表示现在在处理哪个文件。


    piece_len_pow2:分块的大小,BT中把要共享的资源分成固定大小的块,以便处理。这个参数就是用2的指数表示的块的大小,例如当该参数为19的情况下,则表示共享的资源将被分成512k大小的块为单位进行处理。


    target:目标文件地址,即种子文件的地址。这个参数可以不指定(None),则种子文件将与公享资源处于同一目录。


    comment:说明。一段可以附加在种子文件内的信息。


    filesystem_encoding:文件系统编码信息。


    make_meta_files的主要工作是进行一系列的检查。例如在开始的时候就检查files的长度(元素的个数)和target,当files的长度大于1且target不是None的时候就会报错,因为如果要生成多个种子文件的话,是不能指定target的(这样target只确定了一个种子文件的保存位置)。接下来检查文件系统的编码问题。然后把files中所有以.torrent结尾的项目全部刨掉,剩下的作为参数传递给 make_meta_file进行处理,注意,这个函数一次生成一个种子文件。


    下面来看make_meta_file,它一开始计算出块的大小,以2的指数为基础。接下来找到种子文件的保存地址,如果有target,以target 为准,否则如果要对一个目录生成种子文件,则生成以那个目录名为名称,后缀".torrent"的文件。否则生成以源文件为名称,后缀".torrent"的文件。


    下面调用函数makeinfo来生成一个"info"。这个info是什么东西呢?继续看。makeinfo首先检查传给它的path,看看是单个文件还是一个目录。如果是一个目录的话,则调用subfiles把这个目录下的所有文件全部列出来,这个subfiles设计得比较巧妙,使用堆栈的方法避免了递归调用。从subfiles得到结果后,首先对它们进行排序。然后使用变量fs保存这些文件的列表信息,fs是一个list结构,每个元素包含了文件名称和它的大小组成的二元组。接下来就是记录文件的内容了,下面的这个算法看上去有点晕,其实它的意义是很明确的,每次从要共享的资源里读取长度为 piece_length(就是前面那个以2的指数为基础计算出来的块的大小)的数据,然后计算它的sha消息摘要值。如何做到这一点呢?就是根据那个排好序的文件列表,读出piece_length的长度的内容,如果这个文件长度不够,则再读下一个文件,知道长度够了或者读完所有文件为止。生成一个消息摘要后把它加入到pieces数组中,再读下一块,直到全部处理完。为一个文件生成info的方法类似,只是更简单,直接从这个文件中一块一块得处理即可。最后这个makeinfo返回的info是一个字典,它的数据如下:

    pieces:每一块的消息摘要值的连接。

    piece length:每一块的长度。

    files:文件的列表信息,这里由于文件顺序和生成消息摘要的顺序是相同的,以后BT的客户端根据种子文件的描述,就可以很清晰得确定原始的文件名和它们的大小,再配以消息摘要值,就可以检查下载内容是否正确了。

    name:种子文件的内部名称,种子文件可以被随便改名,但是为了识别它方便,内部还是起了这么一个名称的,通常用要共享的资源来命名它。

    我们注意到flag.isSet多处被检查,其中粒度最小的地方是在读取了一块之后。它返回后将一路返回到make_meta_files结束,这样用户随时可以中断程序的执行。

    在makeinfo返回info这个字典类型的数据后,再调用check_info这个函数对其内容进行检查,这个函数定义在BitTorrent/btformats.py模块中,后面在客户端进行下载的时候还需要检查它。

    最后我们看到的是一个类型为字典的data,其中的元素包含了announce,一个字符串,creation date,一个整型数据,info,又是一个字典,如果有comment的话,那么还包含了字符串类型的comment。

    最后把这个类型为字典的data保存到磁盘上,工作就算完成了。怎么对这种比较复杂的数据类型进行编码以方便保存呢?就是上次提到的bencode。

    所以我们可以看到,一个种子文件就是一个类型为字典的data编码后的情形。


----------------------------------------------------


2. 统一网络服务接口--RawServer

    以后的部分都需要网络服务(种子文件的生成在本地就可以完成,但是通过这些种子文件下载实际的内容和提供跟踪器服务都需要网络),在BT的程序设计中,为网络服务提供了统一的接口,这样程序中的其它部分需要打开一个网络服务时,只需要向这个接口进行注册,并提供相应的处理对象(handler)即可,当网络事件发生时,将会自动这个处理对象中的相关函数进行处理。


    这个统一网络服务接口定义在BitTorrent/RawServer.py中,由它去实际调用和网络插口(socket)有关的库,另外,RawServer还提供add_task功能,可以允许一些任务被延后执行。


    RawServer在初始化的时候,可以从外部传入一个doneflag参数,这是一个Event的数据类型,可以从其它地方触发它,这样可以随时中断 RawServer中的主循环(listen_forever中的)。另外还进行一些内部变量的设置。最后,它给自己增加了一个任务,scan_for_timeouts,这个任务会定时得检查超时的网络连接,并关闭它们。


    我们可以看到add_task的所做的工作就是将要延时执行的任务计算出它的实际执行时间,并把它添加到一个排好序的列表中(funcs),且保持这个列表仍然处于有序状态,这个列表以实际执行时间为顺序。


    当其它模块要提供网络服务时,它首先调用RawServer的create_serversocket函数,这个函数会返回一个socket对象,并且这个socket返回时,已经处于listen状态了。当然,这个时候如果真有外部的网络连接进来,还是不会有什么动作的,因为相应的处理对象还没有注册进来。


    接下来应该调用start_listening函数,这个函数的作用是把得到的网络插口和它对应的处理对象添加到一个字典中,该字典以网络插口的描述符 (FD)为主键。值得注意的是,这个函数名称中虽然有listen字样,但是socket.listen函数却不是在这里调用,而是在 create_serversocket就已经被调用了。传递进来的处理对象的类型没有限制,唯一的要求是它必须包含有 external_connection_made函数,这样当外部网络连接到来时,这个函数就会被调用。处理对象通常还应提供data_came_in 函数来处理网络数据,以及connection_flushed函数来处理数据已经正式发出(相对于还在缓冲区的情况)时的处理,后面两个函数也可以不提供,因为在external_connection_made函数里,可以把新连接的网络数据处理对象重新定位到一个包含有data_came_in函数和connection_flushed函数的对象。start_listening函数处理完后,该网络插口就已经存在于serversockets字典中了。


    而当其它模块要连接到外部网络时,应该调用start_connection函数,这个函数将把网络插口添加到另一个字典single_sockets 中,当然,使用了SingleSocket对象对其进行了一定程度的包装。从后面的分析可以看到,这个SingleSocket对象的主要功能是对输出的数据进行了一定的缓冲,并在不会阻塞的情况下把这些数据实际得写到socket中。start_connection需要传入的处理对象是必须包含 data_came_in而可以不包含external_connection_made的对象。


    在start_listening和start_connection中都用到了poll对象,这是系统提供的一个提供轮询机制的模块,使用文件描述符作为参数,可以得到相应的事件(即该文件描述符对应的插口有数据流入或者留出等),而在这两个函数中,都调用了poll的注册函数,方便后面的poll轮询操作。


    需要注意的是,在上面的这些函数被执行后,网络连接还是不会被处理,因为虽然打开了相应的网络插口,也注册了相应的处理对象,但是整个的轮询机制还没有建立起来。直到listen_forever函数被调用后,这个机制才真正得建立起来。这个函数的主体就是一个无限的while循环,只有doneflag 这个事件可以被用来中止这个循环。它首先做的事情是从添加的任务funcs寻找最近要执行的任务的时间,并与当前时间相减,计算出period,然后用 poll轮询这么长的时间,这样做就可以保证轮询结束后不会耽误外部任务过久。轮询到的结果返回在events里,这是一个列表,它的元素是以文件描述符和事件所组成的二元组。接下来就是根据时间的情况,把需要马上执行的外部任务都执行了,_make_wrapped_call的主要作用就是执行外部任务,只是给它们增加一些意外处理的保护代码。执行完这些外部任务后,调用_close_dead关闭不活跃的网络连接,接下来就是使用 _handle_events来处理前面的poll搜集到的网络事件了。


    _handle_events的主体是一个for循环,检查每一个sock和它对应的event。首先看它是在serversockets字典中还是在 single_sockets字典中,如果是前者,那么这是一个侦听中的插口,再检查网络事件,如果不是出错事件的话,那么就说明是有外部连接到达,熟悉 socket编程的人都应该知道,这时正确的处理方式是建立一个新的socket,然后让侦听中的插口去accept它,以后数据的读写应该在新的 socket中进行。接下来的处理也是这样,新的socket被用SingleSocket包装起来了,并且也被放到single_sockets字典中,因为它和用start_connection建立的socket一样,都是有可能有数据流入的,而侦听的插口只需要处理网络连接。接下来,前面注册的处理对象中的external_connection_made函数被调用了,允许进行一些其它的相关操作,我们注意到,这里处理对象被原封不动得传入到新的SingleSocket中,当然实际上在external_connection_made函数中可以把SingleSocket的处理对象重定向到其它对象中。


    接下来的else语句说明sock在single_sockets字典中,只有一种情况例外,就是os.pipe。这种情况下不用处理这个事件,直接 continue处理下一个事件即可。然后检查事件,如果是出错则关闭该插口,否则就说明是有数据流动,而数据流动无非是流入和流出两种情况,如果是流入的话,就把数据读到一个缓冲区里,然后调用处理对象中提供的data_came_in进行处理,而data_came_in得到的参数直接就是缓冲区中的数据,它不需要再处理socket以及考虑可能会形成的阻塞等问题了。另外由于SingleSocket中对写操作也进行了包装,即如果网络有阻塞的可能,数据也会先写入缓冲区,这样data_came_in中就可以随便调用s.write了。最后如果是数据流出,则调用s.try_write,这个函数实现得也很安全。最后检查是否数据都已经真的发出去了(flushed),如果是,则调用处理对象中提供的connection_flushed函数进行收尾工作。


    以后我们可以看到,在BT的实现中,创建了各种各样的对象,而且这些对象之间有各种各样比较复杂的关系,但是所有的网络服务,都是通过RawServer 来进行的,再具体一些,那就是RawServer这个对象只会被创建一个,而所有要求网络服务的模块都会把网络服务的处理对象注册到这个 RawServer中,方便统一管理。


    最后说一下,今天用google搜索发现原来去年就已经有人分析过BT的源代码,不仅感叹自己孤陋寡闻,不过发现现在的版本(4.0.3)和当时的版本已经有了一些差别,而且我也可以以我的阅读源代码的思路继续前进,提供给大家一个不同的视角,因而决定把我的学习心得继续写完,希望大家能够支持。


----------------------------------------------------


3.1 跟踪服务器(Tracker)的代码分析(初始化)

    Tracker在BT中是一个很重要的部分。这个名词我注意到以前的文章中都是直接引用,没有翻译过来,想了一下,决定把它翻译成跟踪服务器。


    在BT下载中,种子文件表明了要下载的文件的信息和对它进行检查的消息摘要码,但是每个对等客户(peer,以后我把peer全部翻译成对等客户,以区别 client)要获取其它对等客户的信息时,还是要和跟踪服务器联系的。跟踪服务器上面不保存任何和种子所代表的内容有关的文件,它只记录所有下载该种子的机器的IP地址,端口等信息,并在客户向它请求是返回一些这样的信息列表,具体的实际内容,由对等客户之间完成交互。


    跟踪服务器的代码实现在BitTorrent/track.py中,在bttrack.py中只是很简单得一行:


    track(argv[1:])


    这样就把参数传到track.py的track函数。track函数本身也比较简单,处理参数和相关的配置文件,建立一个RawServer,然后用 create_serversocket创建服务器套接字,然后开始服务。关于在BT中使用网络服务上次已经有很详细地介绍,这里不再重复。只是针对 tracker函数的具体情况,分析一下运行到listen_forever后的情况,首先,建立了Tracker对象,打开了在某个端口 (config['port'])侦听的网络服务,这个函数的处理对象是一个HTTPHandler。所以我们要分析程序的流程只需要先分析 Tracker的初始化函数,看看它创建后都做了些什么,然后再看HTTPHandler实际分析它的网络协议。


    在Tracker对象的初始化函数中,首先还是对各种变量的初始化。然后要从一个状态文件中进行一些状态恢复,也就是恢复state变量。这个变量中的值很重要,我们可以需要从一些地方来得知它的结构,状态文件的读取和保存出得不到它的信息,因为这两处的实现方式就是bencode和bdecode,只能保证无论state的结构是什么都能合适得被保存和恢复,由此又看出bencode编码设计的巧妙。但是有一个函数对我们分析state的内部结构很有帮助,那就是statefiletemplate,这个函数检查state中的值是否合法,因此我们可以从这里得到state的一些结构信息。


    首先,state必须是一个字典类型的变量。然后检查每一项的值。如果发现一项关键字是'peers',那么它的值必须也是一个字典,这个字典是一个以种子文件的信息部分的消息摘要值为关键字的字典,由于sha摘要算法比较好得满足了摘要算法的要求,即不同的种子文件它们生成相同摘要的概率极小。而且由于这是由种子文件的内容生成的摘要值,因此即使把种子文件改名,还是可以识别出来是哪个种子文件。因此'peers'的值可以看成是为每一个种子文件记录的信息,那么为每个种子文件记录的是什么信息呢?这个信息又是一个字典,这次以每个对等客户的ID为关键字,每个对等客户在连接到跟踪服务器的时候都会为自己生成一个ID,这个ID怎么生成的以后看客户端的代码可以知道,现在我们知道的是,它的长度必须为20。这个字典的值,嗯,又是个字典,不过这个字典的意义就明显多啦,包括了IP是多少,端口是多少,还剩多少没有下载完。因此state的内容可以看成是这样的:{'peers':{},...},其中 peers的结构是这样的:{hash1:{ID1: {'ip':xxx.xxx.xxx.xxx,'port':xxxx,left:XXXX}, ID2: {'ip':yyy.yyy.yyy.yyy,'port':yyyy,left:YYYY},...},hash2:{...},...}。以上是 state中'peers'这一项。'completed'这一项就相对结构简单了,它记录的是每个种子文件的下载完成情况,它的结构是个字典,以每个种子的信息部分的消息摘要值为关键字,而对应的值就是一个整数,表示该种子文件已经有多少人完成了下载。接下来是'allowed'项,这项记录了该跟踪服务器所关注的所有的种子的信息,仍然以信息部分的消息摘要值为关键字,内容就是该种子文件的实际信息,从后面的分析(对 BitTorrent/parsedir.py的分析)可以知道是哪些信息,另外由于之前对种子文件的内部结构我们已经比较清楚,所以也可以猜出部分。 state中还有'allowed_dir_files'项,这一项也是记录文件信息的字典,但它是以每个文件的文件名为关键字(而不是消息摘要值),每个文件的项目是一个列表,结构如下:[(文件修改时间,文件大小),消息摘要值],就是说,这个以文件名为关键字的字典它的每一个值都是一个列表,这个列表有两个元素,第一个元素是一个二元组,内容是文件修改时间和文件大小,第二个元素是消息摘要值。最后,我们注意到statefiletemplate在处理'allowed'项和'allowed_dir_files'项时还有一些额外的检查代码,即所有在'allowed'项里面出现的元素,它的消息摘要值都必须在'allowed_dir_files'项中出现,且'allowed_dir_files'中所有的项中的值的消息摘要部分必须在 'allowed'中出现,另外'allowed_dir_files'中不得出现重复的消息摘要值('allowed'项本身就以消息摘要值为关键字,而字典的关键字已经保证不会重复)。


    因此现在我们知道了state中的注意部分的结构。下面我们注意这两句:


    self.downloads    = self.state.setdefault('peers', {})
    self.completed    = self.state.setdefault('completed', {})


    这样就把state中的'peers'和'completed'的值传到了downloads和completed中,更重要的是,以后在跟踪服务器的运行过程中,如果'peers'和'completed'的值发生改变(那简直是一定的),state中的相应值也会发生变化,这样,保存dfile时,就可以及时更新state的值了。以后我们分析跟踪服务器运行过程的时候少不了和它们打交道,现在我们可以先记住, downloads保存了所有的下载的客户端的信息,completed保存所有的种子的下载完成情况的统计信息。


    下面的这个for循环根据配置文件处理NAT的问题,以及计算种子的个数。completed只是记录所有下载完成的客户的数目,而只有已经下载完成 (left=0),但是还在downloads中出现(即下载完毕但是没有关闭客户端)的客户端才算是一个种子。这里我们可以很容易得看出,seedcount是一个以信息摘要为关键字,整型为值的统计种子数的一个字典。


    下面是一个计算的变量,times表示了每个种子(以信息摘要为关键字)中每个客户(以客户ID为关键字)的上次的有活动的时间。接下来增加了两个任务,每隔一段时间保存一下dfile,并且检查下载的客户端是否已经有很长时间没有反应的。


    接下来准备一个日志文件,并试图把标准输出重定向到这个日志文件中。


    最后要去寻找该跟踪服务器所关注的所有的种子,即parsedir,这个函数可以自己去看,相信在知道了种子文件的编码格式和前面的状态中的项的要求后,不难分析。总得说来,这个函数做了以下事情,即寻找某个目录下所有的.torrent文件,把这些文件中的信息读取进来,并且排除错误,重复等等不合要求的,然后进行加工,输出符合要求的结果,储存在allowed和allowed_dir_files中,进而影响state。


    现在tracker对象已经建立起来,它已经有它要进行跟踪的所有种子的信息,并且准备好了维护所有连接进来的客户的列表,因此它可以正式开始提供跟踪服务了。下一次我们就可以看看tracker动起来的效果。



3.2 跟踪服务器(Tracker)的代码分析(HTTP协议处理对象)

    上次我们分析了Tracker类初始化的过程,现在开始具体看跟踪服务器是如何提供服务的。


    首先分析Tracker处理对象是HTTPHandler,它定义在BitTorrent/HTTPHandler.py中,这个对象的初始化函数很简单,只是把Tracker.get函数赋值到自己的一个内部变量备用。当有外部网络连接到达时,根据前面对RawServer的分析,我们知道,HTTPHandler.external_connection_made函数会被调用,它维护了自己内部的一个字典connections,以传进来的参数connection(它的类型是SingleSocket)为关键字,值为一个新建立的HTTPConnection,新建立的 HTTPConnection也主要是进行一些值的初始化,另外注意这句:


    self.next_func = self.read_type


    这个变量被指向自己的一个函数,后面我们还会看到,它还会发生变化,以灵活处理数据的不同部分。


    现在可以分析客户端和跟踪服务器的网络通讯协议了。当有数据到达时,HTTPHandler.data_came_in会被调用,从它的代码中我们可以一眼看出,起主要作用的其实是该网络连接对应的HTTPConnection的data_came_in函数,它首先检查donereading标志和 next_func函数,即如果已经完成读的操作或者没有next_func来处理下一步,都直接返回,然后将data(网络中读到的数据)添加到自己内部的buf中,下面是一个while循环,可以看出,它的做法是每次从网络数据中寻找/n值,以该值做为两个不同的处理单元,然后将这个回车前面的部分赋值到val,后面的部分赋值到buf(就相当于buf在这个回车前面的部分砍掉,剩下的留待下一次处理),然后将这个val交由next_func处理,处理的结果返回给next_func,意思就是在next_func里处理完这部分值后,它很清楚下面一部分该由哪个函数处理,然后将next_func 重新定向到它就行了,最后进行一些检查看看还要不要继续处理。这个函数我们可以看出,设计得比较巧妙,能够自动得把一个协议的不同的部分分到不同的函数进行处理,而且即使网络阻塞了,只来了一部分数据,下次又来一部分数据,只要它和buf一整合,next_func永远指向处理下一部分数据的函数。


    从HTTPConnection的初始化过程我们知道,第一部分的数据处理的函数read_type,首先去除空格,然后把它们按照空格符分开,如果有三个词,那么认定它的格式为command path garbage,否则,认为是command path。然后检查command必须是GET或者HEAD,现在也已经可以猜出来path应该是一个URL路径,至此,我们可以看出,客户端和跟踪服务器的通信协议其实就是HTTP协议。接下来就是read_header来读取HTTP的头部。它首先看有没有数据,如果有的话,很简单,只是维护一个字典 headers,且寻找到':',':'之前的就是关键字,之后的就是值,然后next_func还是read_header,就是说,剩下的数据都是一行一行的头部信息。全部读完后,检查headers里面有没有accept-encoding项,这项指定返回的数据的编码方式,只有两种,普通模式 ('identity')和压缩模式('gzip'),然后调用getfunc,其实就是Tracker.get来正式处理用户的HTTP请求,而且已经把请求转化成比较方便的参数,即path(用户的请求URL)和headers信息。处理完毕后,如果返回的结果不是None的话,则调用answer把处理结果返回给用户。


    我们先看answer,看到它的参数,我们就知道,它把返回的结果转化成HTTP协议的要求。传给它的参数是一个元组,包含回应代码,回应字符串,头部数据,正文数据四部分。它首先看是否要压缩,如果是的话,就进行压缩,但是压缩后它把压缩后的数据和之前的数据进行长度比较,如果压缩后数据反而更长,那么就不压缩了。接下来是进行日志的记录,诸如某年某月某日某时某分某人在这里请求了某物,返回了某些数据等等。前面我们注意到在Tracker初始化的时候已经把标准输出重定向到日志文件中了,因此这里的print其实就是往日志文件中写。然后用一个StringIO来处理字符串的操作,可以不断得往里面 write,我们看到,程序按照标准的HTTP应答格式("HTTP 1.0 XXX ResponseStringBlablabla../n")的格式,全部处理完后,一次性地往connection里write,把它传送到网络里,RawServer里面已经帮我们处理好了网络阻塞之类的问题,然后检查,如果数据全部写出去了,那么就关闭这个连接。HTTP协议也确实是这样的,一个请求,一个回应,就完成了。


    现在我们可以看到,在BT中客户端和跟踪服务器之间的通信协议就是HTTP协议,而且HTTPHandler和HTTPConnection已经把 HTTP的很细节的部分全部都处理好了,这就意味着Tracker.get已经得到了一个连接对象,一个用户请求的地址,以及一个字典类型的HTTP请求头部数据,并且这个函数只需要专心得完成处理,并把处理的结果以包含HTTP回应代码(200,404,500等),回应字符串(如Not Found,这样和前面的代码合起来就是HTTP 1.0 404 Not Found),HTTP回应头部数据和正文数据的四元组返回即可。


    下一次,我们就可以很仔细得看Tracker到底是如何得处理用户请求了。



3.3 跟踪服务器(Tracker)的代码分析(用户请求的实际处理)

    通过上一次的分析,我们已经知道了Tracker采用http协议和客户端通信,这一次我们就可以直接分析Tracker.get函数的代码,看看跟踪服务器是如何处理用户的请求的。


    首先是检查IP,一个是通过网络连接直接得到的IP(这个有可能是对方的http代理服务器的IP),另一个是从请求的头部数据中解析出来的IP,通过分析函数get_forwarded_ip和_get_forwarded_ip我们可以发现,它的原理是从头部数据中看有没有这些关键字:http_x_forwarded_for,http_client_ip,http_via,http_from,如果有的话,就说明当前的 http连接中的网络的另一头是一个代理服务器,而不是实际的客户端,有些http代理服务器会提交这些http请求的头部数据告诉服务器客户端的真实 IP地址。还有就是要检查这些IP地址是否合法,是否为本地地址 (10.*.*.*, 127.*.*.*, 169.254.*.*, 172.16.*.*-172.31.*.*, 192.168.*.*)等,然后确认真实的IP值。接下来准备好paramslist准备处理请求参数,这是一个字典,但是它的每个元素的值是一个列表,这种情况在http请求的参数列表中很常见。


    接下来用urlparse把用户的http请求分成标准的几部分,例如,类似如下的请求:

    http://xxx.xxx.xxx/path;parameters?query1=a&query2=b&query3=c#fragment


    将被分割成这样的6块:http,xxx.xxx.xxx,path,parameters,query1=a&query2=b&query3=c,fragment


    接下来把query中的值按照'&'以及'='进行处理,把它们填入到paramslist中,确认出请求的参数。下面就是根据各种情况返回给用户适当的结果了。


    首先,如果用户请求路径是空的或者是'index.html'(对应于http://xxx.xxx.xxx/或者http://xxx.xxx.xxx/index.html),那么就返回给用户一个信息页面,使用get_infopage函数完成,当然,在实际运行中,可以把配置文件设置为不允许显示信息页面,这样 get_infopage就会返回HTTP404代码,否则,生成一个页面文件,它包含了一些基本信息,以及该跟踪服务器目前关注的种子文件的列表以及它们的一些统计情况。接下来的情况是用户请求路径'scrape'或者'file',也是返回相应的信息给用户。其中,get_scrape返回的各个种子文件的统计信息,例如某个种子有多少下载完了,多少人还没下载完成等。而'file'则是直接获取某个种子文件(即允许用户直接从跟踪服务器下载种子文件)。注意在以上的请求过程中,表示某个具体的种子文件的关键字是它的信息部分的消息摘要值(infohash)。


    在处理完另一种情况,用户要求返回图标文件后,接下来就是跟踪服务器的主要功能,announce了。所以如果路径不是announce的话,那就返回 HTTP404。下面先获取infohash,即客户端请求的是哪个种子文件的情况,然后进行check_allowed检查,排除有些种子不能在此跟踪服务器上下载的情况。这样可以及时得对跟踪服务器做一些授权方面的操作,例如如果发现有人发布了有违反国家法律法规的内容的种子,跟踪服务器的维护人员只需要及时得把这个种子的infohash添加到这个列表中,就再没有人可以下载这个种子了。


    在排除了各种意外的情况后,就可以用add_data把用户的信息添加进去了。一个跟踪服务器在获取了一个客户端要求下载某个种子文件的请求后,它把这个客户的信息记录起来,这样他就知道有哪些客户对某个具体的种子文件感兴趣。然后再根据这些列表,选取一些客户的信息返回给客户,而要下载这个种子文件中的实际共享资源?自己去找其它客户(客户称呼其它客户时应该叫对等客户)解决吧,跟踪服务器上一个字节都没有。


    add_data首先从downloads这个字典中找到关键字为infohash的项,赋值到peers中,如果downloads中没有这项,则新建这项(setdefault,详见python库参考手册之内建类型字典的详细说明),然后检查传进来的参数是否合法,如peerid必须是20个字节,event(如果有)只能是'started','completed','stopped','snooped'中的一项等。peers是一个以 peerid为关键字的字典,因此先看看原来有没有这个peer,用peerid把它取出来。然后准备好rsize,这个是返回的项(即返回多少个对等客户的信息给客户)的数目,它由客户的要求以及服务器的配置参数共同决定。然后如果用户的请求中有event=stopped项,则删除该客户的信息,否则,如果peer为空(peers中没有这个peerid的peer),则设置好一个新的peer的信息,然后添加进去,如果peer已经存在了,那么就更新一下它的信息。最后返回rsize完成任务。其中有一个NAT处理的类,定义在BitTorrent/NatCheck.py中,它通过往用户声称的 IP和端口进行一次连接,看看有没有反应,以确认NAT情况,并且记录下来。最后还要把这些数据放到becache中,这个地方存放经过一定格式处理后的客户数据,可以加快返回客户信息的函数的处理过程。becache中所有的客户信息都按照下载完成(做种)和未完成分开了,这个可以方便服务器返回一定的做种peers和非做种peers给客户。


    在add_data把客户的数据更新好后,接下来就是用peerlist函数返回一定量的客户信息了。它根据用户的要求(rsize),以及所有的 peers中种子数占的比例,决定返回给客户的信息中,有多少是做种的peers,即返回给客户的做种peers占所有返回给客户的peers的比例,应该接近于所有在下载这个种子的做种peers和全部peers的比例。然后把两部分的信息复制到一个cache中,再用shuffle把它们打乱,最后把这些信息返回给客户。因此我们可以看到,跟踪服务器返回用户信息的准则是,在保持种子所占的比例接近全局的种子比例的情况下,随机选取客户信息返回给客户。


    今天把跟踪服务器的代码全部分析完了,下一次就可以开始客户端源代码分析了。 


----------------------------------------------------


4 客户端源代码分析(图形界面浅析)


    客户端将从btdownloadgui.py开始进行分析,这样可以顺便把Python中的GUI编程也看一下。Python中的GUI编程也有很多内容,所以不可能深入得分析,仅仅以BT的源代码为例看一下。


    btdownloadgui.py中使用gtk作为其图形界面的开发库。这个库中提供了很丰富的类,可以来创建图形界面中所需要的各种widget,而在主要的窗口类DownloadInfoFrame的初始化过程中,程序的主要任务就是创建窗口中要用到的各种各样的widget,并且把它们加入窗体。然后用connect函数把某个widget上可能会发生的事件与某个处理函数连接起来,这样,一个GUI界面就建立起来了。具体的过程可以参考gtk的每个类的说明文档,并不困难。


    这里再简单介绍一下btdownloadgui.py中的其它的类。它们通常都是GUI界面中的各种子窗口,需要在某个按钮或者菜单被选中后弹出。我们也可以看到作者在GUI界面方面的设计的一些巧妙之处。


    Validator:在一个基本的Entry上继承的类,但是可以对用户输入的值进行判断,只有出现在有效字符列表中的输入有效。另外在这个类的基础上还继承了IPValidator,PortValidator,PercentValidator,MinutesValidator对各种输入数据进行校验。


    RateSliderBox:这是一个表示速率的滑动块,可以根据鼠标的调整显示出对应的速度以及相应的网络连接类型。内部完成了滑块位置和对应速率的转换。


    StopStartButton:停止/开始按钮,可以通过点击这个按钮来临时停止或者恢复所有的种子的下载。


    VersionWindow(显示版本信息),AboutWindow(显示关于信息),LogWindow(显示日志),SettingsWindow(进行一些参数设置),FileListWindow(文件列表窗口),PeerListWindow(对等客户列表窗口),TorrentInfoWindow(种子文件信息窗口)都是一个子窗口,它们在相应的功能被调用时会弹出来,其中LogWindow使用到了 LogBuffer中的内容,即在任何需要记录日志的地方调用LogBuffer的log_text函数,然后LogWindow可以把它们都显示出来。


    TorrentBox是一个种子文件下载任务在图形界面上的表示的基类。里面定义了名称标识,状态图标,进度条等等GUI元素,所有的下载任务都以这个类为基类。KnownTorrentBox则是已经不在下载任务列表的图形界面表示,可以把它拖到下载队列中,这样就可以继续下载这些任务,这有两种情况,把已经完成的任务拖到下载队列中就表示要继续做种,而把已经失败的任务拖到下载队列中就表示要恢复下载。DroppableTorrentBox是一个增加了托拽对象处理的类。以DroppableTorrentBox为基类的类有 QueuedTorrentBox,PausedTorrentBox,RunningTorrentBox分别对应了还在队列中的下载任务,暂停的任务和正在运行的任务。KnownTorrentBox的区域中的项目可以托拽到下载队列中,另外QueuedTorrentBox和 PausedTorrentBox,RunningTorrentBox中的项目可以自由托拽,满足用户对下载任务的队列管理要求。这里还要提一下,那就是RunningTorrentBox和PausedTorrentBox不能同时出现,它由开始提到的StopStartButton管理,即暂停/运行所有的任务,而QueuedTorrentBox是那种由于同时下载的任务的数量的限制而暂时得不到下载的任务,即使StopStartButton的状态是运行所有任务,它也不会被运行,只有当某个下载任务结束或者中止后,系统从队列中选取一个进入运行队列。


    以上是每一个具体的运行项目对应的widget,下面来看它们的容器,HSeparatedBox是一个容器的基类,定义在 BitTorrent/GUI.py中,这个模块中还定义了其它一些为GUI显示方便的函数和对象。HSeparatedBox中可以添加若干子窗口,并且可以对它们进行重新排列,另外它可以用分割线分割添加进来的子窗口。DroppableBox是HSeparatedBox的子类,它增加的功能是处理托拽对象离开,KnownBox则又是DroppableBox的子类,它将被填充进若干个KnownTorrentBox,对应的还有 RunningBox和QueuedBox,填充入相应的下载任务的widget(注意到PausedTorrentBox和 RunningTorrentBox不能同时出现,它们都被放在容器RunningBox),而RunningAndQueueBox是一个同时塞进了 RunningTorrentBox和QueuedBox的容器。


    系统中每个TorrentBox保存某个具体种子的信息仅仅是为了显示,当需要的时候,这些widget都是随时被删除,或者创建,或者进行重新排列,这些种子的随着运行的时间一些统计信息发生改变,也会通过这些widget显示出来。


    BitTorrent/TorrentQueue.py模块完成了从GUI界面到实际的种子下载任务的模块中的衔接。它实现了Feedback的接口,这样当某个种子的下载任务发生变化(如完成,出错等)后,可以及时得通知它,然后显示在GUI界面上。通过分析其它的BT的下载程序(即非图形界面的下载程序,如btdownloadheadless.py等),也可以看到有一个继承了Feedback接口的类,但是它的实现方式通常是以文字的形式表现出种子的变化。另外,TorrentQueue模块还可以恢复上次的下载情况(读取一些状态文件),通过所有的下载程序的分析(图形界面的或者文本的)我们可以认为,实际执行下载任务的对象是BitTorrent/download.py中的Multitorrent对象为。而TorrentQueue的代码仍然算在GUI部分中,通过分析它的代码,我们已经知道它已经读取了相应的种子文件中的信息,并且进行了相关的处理。


    由于我们主要是要分析BT的客户端的实际功能代码,因此GUI部分只能比较简略得说一下,其实这部分可以对着gtk的参考手册看相关的图形界面程序的源代码(btdownloadgui.py,btmaketorrentgui.py),还是比较好分析的。下一次开始就可以分析 BitTorrent/download.py中的Multitorrent,直奔主题了。

 


----------------------------------------------------



5.1 客户端源代码分析(相关对象一览)

   

    BitTorrent/download.py中的Multitorrent对象能够开始实际的下载任务。要开始下载,需要创建一个 Multitorrent对象,然后反复得调用start_torrent方法开始一个新的下载,调用这个方法时必须已经准备好相应的下载任务的信息作为参数,包括已经处理好的元信息(经过BitTorrent/ConvertedMetainfo.py模块进行处理),配置信息,一个实现了 FeedBack接口的类(这样种子在下载的时候状态发生变化可以及时反映出来,至于是反应在文字信息上还是在图形界面上那就看这个FeedBack接口的实现),以及保存种子文件内容的本地目录。这个函数会返回一个_SingleTorrent对象,代表一个单一的种子文件下载任务,这个对象前面的一条短线代表它是私有对象,不能单独创建,只能通过start_torrent来进行创建。它除了使用FeedBack接口来反应状态变化以外,还可以允许界面模块主动地调用_SingleTorrent.get_status来获取关于该种子文件下载状况的一些统计信息。当然不要忘记调用 multitorrent.rawserver.listen_forever()开始这一切的调度,在创建multitorrent类时,它会在内部创建一个rawserver。


    前面几次都是直接上来就通过流程来分析程序,但是这次不一样,因为客户端的程序结构比较复杂,而且各种对象之间互相关联,必须先对这些对象的功能有一个大致的了解才好继续分析,因此这一次将简要得介绍一下客户端的下载程序中牵涉到的主要对象。


    Multitorrent:下载任务管理的主对象,定义于BitTorrent/download.py中,内部维护了一个RawServer,且可以创建_SingleTorrent(与其定义于同一模块中。)它内部还维护了其它对象。


    SingleportListener:管理网络连接,是Multitorrent中的RawServer的网络连接处理对象,定义于BitTorrent/Encoder.py中。


    FilePool:管理文件池,定义于BitTorrent/Storage.py中,它可以保证同一时刻打开硬盘上的文件数量在一个限定的值以内。


    RateLimiter:速度限制类。定义于BitTorrent/RateLimiter.py中,它可以控制全部种子文件下载时上传的速度。


    Storage和StorageWrapper:储存管理类。定义于BitTorrent/Storage.py和StorageWrapper.py 中,它们的作用是对程序的其它部分屏蔽掉种子文件中第几块对应于实际硬盘上的哪个文件的偏移量多少。即它对程序的其它部分提供诸如以下的这些服务,确定现在本地有第几块,没有第几块;应其它部分要求读出第几块(其它程序就不用管第几块实际上是硬盘上的那个文件),然后它们好发送到网络上;其它部分从网络上得到一块新的数据,叫它存储到硬盘上。Storage和StorageWrapper都和_SingleTorrent一一对应。


    Choker:阻塞管理类。定义于BitTorrent/Choker.py中,它的作用是确定上传的阻塞策略,即当前的连接中,阻塞哪些连接。与_SingleTorrent一一对应。


    Measure:速度测量器。定义于BitTorrent/CurrentRateMeasure.py中,它的作用是计算速率。在_SingleTorrent中定义了若干Measure对象来计算各种速率(如上传,下载等)。


    RateMeasure:也是速度测量器。定义于BitTorrent/RateMeasure.py中,和Measure不一样的地方在于它可以在初始化的时候传入一个表示还剩多少字节的参数进去,因而它多了一个功能,那就是根据当前的速率,估算出预计剩余时间。_SingleTorrent中定义了一个RateMeasure。


    PiecePicker:块选取器。定义于BitTorrent/PiecePicker.py中,进行“下一块下载哪块”这件事情的决策工作,与_SingleTorrent一一对应。


    Downloader:下载工作管理器。定义于BitTorrent/Downloader.py中,管理该种子任务中的所有下载工作。因为一个种子文件的下载过程中要和很多个对等客户打交道,因此需要建立若干个连接。与_SingleTorrent一一对应。


    Encoder:连接管理器。定义于BitTorrent/Encoder.py中,管理该种子文件任务中的所有连接(不管是主动连接到其它对等客户上或者是其它对等客户连接到本地),与_SingleTorrent一一对应。


    Connection:连接。定义于BitTorrent/Connecter.py中,一个该对象对应于一个连接。因此一个_SingleTorrent中包含了若干个Connection对象(由Encoder负责统一管理)。


    SingleDownload:单一下载。定义于BitTorrent/Downloader.py中,对应一个连接中的下载。它与Connection 一一对应,且由Downloader对象产生(Downloader.make_download),每次新的连接建立时,Encoder都会把这个连接保存起来,并且产生一个SingleDownload对象。


    Upload:单一上传。定义于BitTorrent/Downloader.py中,对应于一个连接中的上传。和SingleDownload一样,它与Connection一一对应,每次新连接建立时,由Encoder产生。


    Bitfield:位图对象。定义于BitTorrent/bitfield.py中,用来表示一个比特数组。它典型用途是表示当前的种子文件的下载过程中,本地有第几块,没有第几块。出现在两个地方,StorageWrapper,储存本地的块拥有情况信息,以及SingleDownload中,储存别人的块拥有情况信息(以方便决定以后是不是要从他那里下载)。


    Rerequester:跟踪请求发生器。定义于BitTorrent/Rerequester.py中,作用就是和跟踪服务器打交道,来获取对等客户的信息。与_SingleTorrent一一对应。


    DownloaderFeedback:下载任务状态信息搜集器。定义于BitTorrent/DownloaderFeedback.py中,它提供了搜集下载任务的状态信息的接口,可以完成状态信息的搜集以显示给用户。图形界面程序或者其它的界面程序在调用_SingleTorrent的搜集信息函数时,最终还是要和该对象打交道(可以参阅_SingleTorrent.get_status函数的实现)。与_SingleTorrent一一对应。



5.2  客户端源代码分析(存储管理)


    这一次分析BT的存储管理。我们知道,BT把要共享的资源化分成统一大小的块,并且在种子文件中记录每一块的消息摘要值,以便在下载时确定某一块是否已经正确下载。而且在前面的种子文件的制作过程中我们已经看到,除非是最后一块,其它的块大小都是相同的,因此很有可能出现在一个文件的开始多少个字节属于某一块,然后从中间偏移多少字节又属于某一块,或者在文件比较小的情况下某一块包含了若干文件等。而BT的存储管理部分就对程序的其它部分屏蔽了这些区别,即对其它部分而言,只需要按照块来进行存取。


    首先了看FilePool类,它在Multitorrent中定义,就是说,全局只有一个。因此它可以保证多个种子文件在下载时硬盘上的文件被打开的数量限制在一定数量。内部维护了如下变量:handlebuffer为所有已经打开的文件的列表,allfiles是一个字典,记录所有的文件的拥有者情况,即哪个文件是属于哪个种子文件。它的关键字是各个文件的文件名,值则是对应的_SingleTorrent对象。handles则是记录文件名与对应句柄关系的字典,whandles还说明了哪些文件是可写的,注意,在whandles中,并没有储存对应句柄,即如果有一个文件出现在handles中,可以通过handles直接获取其句柄,避免多次打开或者关闭文件,而如果它出现在whandles中,那么说明它还是可写的。


    Storage是在每个_SingleTorrent中被定义。创建Storage需要一个文件和它们对应的大小的列表,文件的大小方面的信息可以从种子文件的元信息里得到,另外,必须要把种子文件的元信息的文件列表中的每一个项目都加上在硬盘中实际保存的目录,以便可以直接对应到某个具体的文件。 Storage在创建的时候建立文件名和全局的字节之间的映射关系,即列表ranges,该列表的每一个列表项是一个三元组,起始偏移,结束偏移,文件名。它的意思是种子文件的内容中,从第几个字节到第几个字节是属于哪个文件。另外,假设种子文件中对所有信息的内容进行了块的划分,设这个块长为 piecelen,那么每一块还有一个字节偏移,如从第0个字节到第piecelen(不含)个字节是属于第一块,接下来属于第二块等。因此这个 Storage类就要解决BT的下载按块进行和硬盘中按文件进行存储之间的矛盾。这里再提一下,之所以把piece翻译成块,是因为后面的BT的下载过程中,还要把每一块再切分成若干的slice,而我习惯于把slice翻译成片。


    在Storage中,有两个私有函数_intervals和_get_file_handle,它们给read和write提供了两项重要的功能,而 read和write是Storage对外提供的重要接口。_intervals的任务是提供一个全局的偏移量和长度,返回一张表,说明要对这些数据进行访问应该分别访问哪些文件的第几个字节开始的多少字节。这样,在read和write里面就可以用for ... in _intervals(xx,xx)了。而_get_file_handle则是获取一个文件的实际句柄,以便对其进行读写,在Storage中获取文件的句柄要用一个函数来处理的原因是必须考虑到文件打开数量的要求限制,因而在_get_file_handle中要和FilePool打交道,在打开一个文件句柄的同时,要对FilePool内部的一些变量进行维护。


    Storage还有一项功能就是读写一个“快速恢复”状态文件。它只负责读写一部分,这个文件中还有一部分数据由StorageWrapper类来进行读写。由Storage类负责读写的部分是文件头,包括'BitTorrent resume state file, version 1'字样,和总的数据量,以及各个文件的大小和更改时间等。


    StorageWrapper类则是在_SingleTorrent._start_download中定义,提供的接口要更加高级。例如,它提供按照某一块来进行访问,然后在内部通过把块号乘以一块的大小来得到实际的字节偏移量,然后让Storage来进行读写。另外,它维护了一张本地拥有哪些块的比特数组have,可以方便决策。还有两个表示块的存储状况的数组,places和rplaces,它们的意思是数据的第几块存储在硬盘上的第几块以及硬盘上的第几块存储的实际上是数据的第几块。这个数组基于两部分数据的抽象:第一部分的数据抽象是把种子文件中所表示的内容(即共享的资源)看成是一块连续的数据,这块数据有若干块。第二部分的数据抽象是把存储在硬盘上的文件,看成是一块连续的数据,即连续的存储空间,也分成若干块。当下载任务全部结束后,应该有对于0到self.numpieces-1,有places[i]=rplaces[i]=i。而在下载过程中,由于采取了一定的策略,不一定是先下第 0块,再下第1块等,因此在这个过程中有可能places[i]和rplaces[i]中的值不等于i。
   
    我们先来看一下比特数组,即Bitfield,它定义在BitTorrent/bitfield.py中。它用最节约的空间完成了比特数组的存储,即比特数除于8的字节数。另外,它实现了__setitem__和__getitem__,这样就可以直接对have[i]进行读写操作来完成值的操作。注意到在__setitem__的实现中有assert val,这就意味着只能把数组中的某一项赋值成1。这项功能比较适用于表示块拥有的状况,即某一块只能从没有到有,不能“得而复失”。


    StorageWrapper还有一项很重要的功能就是对每一块数据再次细分成若干个slice,而一个slice就是两个对等客户之间通过网络进行数据交换的最小单位。在此基础上,它要负责生成请求,inactive_requests储存的就是所有可能的请求。在看这部分代码时,注意1和l的区别,在初始化时,inactive_requests[i]的值是1,表示某一块还没有(因而可以为此生成网络请求),当得知某一块已经获取时,inactive_requests[i]的值变为None,具体得知某一块已经获取时要进行的操作为markgot,它的意义是第piece块在硬盘上的存储空间中的第pos块被发现。另外,初始化的时候调用_check_partial和_make_partial检查某个具体的块,看看有哪些 slice还需要下载。把这些请求放到inactive_requests中,以后当程序其它部分决定要开始下某一块时,StorageWrapper为其生成相应的网络请求的参数(第几块,偏移多少,请求多少长度的数据),new_request即完成这项工作,另外piece_came_in和 get_piece提供数据的读写操作,调用它们的时候都要指定index(第几块),begin(块内偏移),length(长度,在 piece_came_in中是piece,即数据本身,可以直接得到它的长度)。


    最后,关于存储管理这部分,还有一点需要提到,那就是早期的某个BT版本是在下载刚刚开始的时候就申请好相应的硬盘空间。而现在则是随着下载的进行文件逐渐增大。但是下载的顺序很可能不是先下第0块,再下第1块这样,因此在文件中存储的顺序也不一样,这样当新的下载数据到来存储到硬盘上时,很可能就要对起进行调整,尽可能让它们“对号入座”。_move_piece函数就能进行数据的移动,而参考piece_came_in开头部分对 _move_piece调用的代码就可以理解BT在下载过程中逐渐使块的顺序“对号入座”的这个过程。

 

5.3 客户端源代码分析(从开始到连接建立阶段)


    这一次开始恢复按照过程进行描述,即从Multitorrent.start_torrent函数的执行开始。


    通过前面的分析,我们知道当Multitorrent.start_torrent被调用时,一个新的种子下载任务就开始了。这个函数本身很简单,就是创建(并返回)一个新的_SingleTorrent对象,然后让其start_download方法开始调度。start_download这个函数一开始看上去有点奇怪,其实这是作者设计的一个小技巧。python中的yield关键字可以使一个函数返回一个值,但是它的内部执行状态仍然保存,这样下次调这个函数的时候,这个函数就继续从那里往下执行。可以用诸如it = self._start_download(*args, **kwargs) 这样的形式来确定一个迭代器,注意在执行这一句的时候,_start_download并未得到执行。要使包含有yield的关键字的函数得到执行,只需要反复调用it.next(),这将返回每次yield出来的值,当函数执行到结尾时,将会抛出一个StopIteration异常,通过捕捉这个异常就可以知道函数以及执行完毕。在start_download中干了以下事情,把一个函数赋值到_contfunc,并且执行了它一次。这个函数的实际内容就是开始执行_start_download,为什么要这样费一下周折呢,这样做的目的就是为了在合适的时候分出一个线程。到目前为止,程序还是只有一个主线程在运行。继续往下看_start_download函数,根据元信息的文件列表和保存到硬盘上的地址,整理出一个实际的文件列表,可以直接对它们进行存储。然后创建一个新的Storage对象,它需要的文件名和大小的元组列表可以通过zip函数得到,这个函数的功能是从第一个参数(列表类型)中获取第一个元素,然后和第二个参数的第一个元素组成一个元组,再将第一个参数和第二个参数的第二个元素组成一个元组等,最后变成了一个列表。然后进行“快速恢复”的文件检查。接下来注意到函数hashcheck,通过创建一个新的线程,然后让它开始运行,接下来yield None,注意,从这一句开始,其实就已经返回了。hashcheck函数将在新的线程开始执行,我们来看看hashcheck函数中都干了什么,主要就是创建了一个StorageWrapper类,它初始化时就已经对硬盘上有的内容确定下来了。然后,它执行了_contfunc()!没错,执行它的效果就是从yield None后面的部分继续执行下去了,但是,这时已经是在另一个线程中。接下来创建一个Choker,以及一些速度测量器。然后要创建一个 PiecePicker,初始化完成后,还要告诉PiecePicker哪些块已经拥有了(PiecePicker.complete)以及哪些块已经下了一部分(PiecePicker.requested)。下面创建一个Downloader对象,但是对于Upload,只是定义一个函数 make_upload,它能够随时生成一个Upload对象。然后创建一个Encoder对象,注意它把Downloader和make_upload 做为参数,从结构上来说,它们就被绑定到一起了。接下来要用add_torrent把一个种子文件(以infohash为关键字)和它的Encoder绑定到一起,这样,当收到其它的对等客户的连接的时候就能够知道对方要下载的是哪个种子文件了。最后创建Rerequester和 DownloaderFeedback这两个对象。


    最后执行Rerequester.begin,启动它,让它开始和跟踪服务器交互,然后就可以调用feedback接口的started函数,意思就是说,我们已经开始了,至于是用图形界面还是文字界面向用户表示这一事实那就是feedback接口的事情了。


    Rerequester。它的作用即为向跟踪服务器要对等客户的信息,前面通过对跟踪服务器的代码分析已经对客户端和跟踪服务器之间的通信协议有了一个基本的了解。我们称和跟踪服务器进行一个http请求并获取它的回应数据的过程称为一次发布 (announce),Rerequester的begin函数能够保证自己每隔60秒_check一次。我们来看_check一次要做什么:首先要保证两次发布的时间间隔不能过短,另外要根据自己的peerid制作 url(_makeurl:http://xxx.xxxtracker.xxx:xxxx/announce?info_hash=xxxx& peer_id=xxxx&port=xxxx&key=xxxx),根据不同的情况调用_announce进行一次发布。给 _announce的参数的意义是'event'参数的值,这些'event'的意义可以在跟踪服务器的代码分析中看到,它们确定了下载的不同的阶段。因为平时也还需要经常补充一些对等客户的信息,所以_announce会经常被调用。它的主要任务是对url进行进一步的加工,计算出当前发布时所用的 url,保存在s中,然后用一个新的线程开始执行发布,使用新的线程的原因是不希望到跟踪服务器的网络阻塞影响到程序的其它部分的执行。 _rerequest就基本上可以只管发出这个http请求了,当然,它开始的部分代码是要排除一些自己的外部IP和实际IP不相同的这种情况。 Request是zurllib中的模块,可以很轻松地发送一个http请求,然后获取返回的信息。根据是否出错来决定调用_postrequest的情况。这里出错仅仅是http请求本身发生错误,如网络问题等,跟踪服务器也可能会返回一些其它的错误信息,我们可以在_postrequest中看到。


    _postrequest首先便是检查是否有错误信息,然后把data进行bdecode,这个过程我们已经很熟悉了。接下来用check_peers检查看这是不是一个规范的对等客户信息数据,check_peers在BitTorrent/btformats.py中定义,btformats.py还有其它的检查信息格式的函数。下一步是检查r中有没有关键字'failure reason',如果有的话,那就是说到跟踪服务器的网络没有问题,但是跟踪服务器返回了其它的失败原因,这样还是一种失败的情况。下面就是把r中的关键字为'peers'部分的数据解析出来了,这部分传回来的数据有可能是紧凑的字符串也有可能是一个字典,在跟踪服务器的代码分析中我们可以看到这一点。最后就可以调用connect函数试图逐个得与对等客户建立联系了。connect函数实际上是Encoder.start_connection。

    下一次就可以开始分析两个对等客户之间的通信协议了。


5.4 客户端源代码分析(对等客户的连接建立及其握手协议)

   

    上一次我们分析到了一个客户是如何得获取到对等客户的信息,现在终于要开始建立连接了。这一次我们将分析两个对等客户之间的连接的建立以及连接对象为它们之间通信提供的基础框架设施。


    Encoder.start_connection建立到某个对等客户的连接。dns参数是IP地址和端口号,id是对方的peerid。首先检查对方的 IP地址是否在banned列表内,如果在,直接返回,就是说不会再和对方建立连接。这就是通过一个黑名单的机制,避免和某些对等客户连接。这个黑名单也有一些生成的策略,后面我们可以看到,通常是发现某个对等客户传来的错误数据过多就将其加入到黑名单中。然后,当然对方id不能等于自己的id,以及在已有的连接中进行查找,不能重复连接。然后是检查连接数,如果连接数大于某个配置值,那么将这个连接的信息暂存入spares列表,日后再取出来。接下来就可以让RawServer进行网络连接了。如果网络连接成功建立,那么一个新的Connection对象就会被创建,并且该网络连接的数据处理对象 (data_came_in)也会被交给这个Connection对象。


    现在我们来看看网络的另外一头,就是说对方收到连接后会执行什么代码。由于在Multitorrent中定义了一个SingleportListener 来侦听本地端口,也就是说,所有的外部网络连接的处理对象都是这唯一的SingleportListener,那么很自然,对方收到连接后,SingleportListener.external_connection_made会被调用。注意到SingleportListener和 Encoder都定义在BitTorrent/Encoder.py中,看来作者是认为它们关系比较紧密。 SingleportListener.external_connection_made中所做的事情也是创建一个Connection对象,并且完成网络连接的数据处理对象的重定位工作。


    两种不同的情况都会有一个新的Connection对象被创建,但是初始化它们的参数不太相同,另外它们都会被维护到一个字典中,Encoder中的这个字典记录了某个种子文件下载任务的所有连接,而SingleportListener中的这个字典记录了所有外部来的连接(就是说,还不知道应该把它们交由哪个Encoder进行管理)。


    现在我们来看Connection对象被创建时所做的初始化工作。第一个参数是encoder,就是说该Connection对象属于哪个encoder管理,这个参数也有可能是SingleportListener,第二个参数就是由RawServer创建的 SingleSocket对象,它通常是用来作为所有连接的字典中的关键字,另外可以用它来完成具体的网络读写操作。第三个参数是id,指的是对方的 peerid,如果是外部连接(SingleportListener处理的),那么这个参数是None,即还不知道对方的peerid,最后一个参数是一个布尔值,说明该连接是本地发起的(Encoder.start_connection)还是外部连入的 (SingleportListener.external_connection_made)。开始的初始化工作基本上是对一些变量的初始化,注意到 _reader变量,_read_messages()函数是一个多次使用了yield关键字的函数,所以这里_read_messages没有被执行,然后下面的_next_len的赋值部分,使_read_messages()执行了一些,即开始的yield 1。这样_next_len就等于1,且_read_messages()函数执行冻结在了这里。最后,如果是主动连接的话,那么就往网络上发送后面的那一串东西。如果不是主动连接就不用了。发送的那些数据就是BT通信协议的握手部分的内容。


    现在就可以来看Connection.data_came_in函数是如何处理到来的网络数据。_next_len表示的是下一条完整的消息的长度,_buffer是缓冲区中暂存的信息(因为还没有达到_next_len的要求),_buffer_len则是缓冲区中的信息的长度。每一次试图从网络数据s中得到组成下一个完整的消息的数据,因此首先计算长度,_next_len-_buffer_len说明要从s中得到多少数据。如果s中没有这么多数据就把s中的数据暂存到缓冲区中,然后就返回。这样下一次调用data_came_in时就可以继续组建需要的数据了。如果s中有足够的数据,则将其组成合适的长度(_next_len),放入_message中,以方便处理。然后让_read_messages()继续往下执行。如果s中还有数据,则while循环要继续进行。我们看到,在data_came_in的这种设计框架下,_read_messages()函数每次yield一个值,当它接下来恢复执行时,_message中就已经有它要的值了。


    这样,_read_messages()就可以专门处理协议。我们现在就可以对比本地发起的网络连接的初始化过程中发出的那个握手字符串来分析握手协议。首先yield 1,然后一个字节的数据进入了_message,这就是chr(len(protocol_name)),协议名称的长度。通过把这个字符还原成整数,看它是否和协议名称相同。接下来yield len(protocol_name),然后进入_message的数据就是protocol_name,检查看它是否是'BitTorrent protocol',然后yield 8,这是8个字节的保留串,不用进行任何处理,继续yield 20。这是download_id的值,encoder.download_id是什么呢?就是种子文件的infohash。然后检查 self.encoder.download_id,如果是None,那么说明这个Connection对象是SingleportListener建立的,就是说这是网络中来的连接,因此程序运行到这里就可以做的一件事情就是看看这个infohash到底是本地的哪个下载任务,更准确的说,这个 Connection对象应该交给哪个Encoder进行管理。所以它调用了encoder.select_torrent(其实就是 SingleportListener.select_torrent),这个函数从维护的torrents字典中根据infohash查找对应的 Encoder,然后让Encoder.singleport_connection进行Connection的交接。在 Encoder.singleport_connection中做的事情包括检查对方的IP是否在banned列表中,否则拒绝其连接。然后将 Connection对象添加到自己维护的字典中,并且将其从SingleportListener的字典中删除,然后将Connection的 encoder指向自己,这样这个Connection就正式归这个Encoder管理了。再返回到_read_messages()函数中,对 encoder.download_id的检查就可以证明以上过程是否成功完成了。下面一个elif则是主动的连接,对方返回的download_id在 _message中,如果和自己的encoder.download_id不符,则中断该连接。下面检查是否是本地发起的连接,如果不是本地发起的连接,则也向对方发送握手协议(这样对方的_read_messages()函数也可以开始运行了)。接下来yield 20,得到peer id。这是对方的ID,Connection中保留的id都是对方的ID,自己的ID保留在Encoder中。下面的这一段代码对得到peer id进行处理,如果需要则保留到自己的id变量中,并且根据自己的Encoder的Connection字典进行检查,以避免两个对等客户在同一种子文件下载任务中的重复连接。


    在握手协议的最后一步调用了Encoder.connection_completed,说明这个连接建立成功,可以正式进行数据的交互了。在这个函数中做的工作就是为这个Connection生成一个Upload和SingleDownload对象,并且把这个Connection交给Choker进行管理。


    回到_read_messages()中,下面我们可以看到,握手协议已经成功完成,开始传送其它数据。每一条消息都分割成一个四字节长的长度和消息本身,因此while循环中不断的yield 4和yield l,然后_got_message来处理每个消息。


    通过这一次的分析,我们知道了两个对等客户之间的连接的握手协议,以及Encoder, Connection, Upload, SingleDownload这些基本对象在连接建立时的基本关系。下一次就可以开始分析BT通信协议中的其它部分。

 

5.5 客户端源代码分析(对等客户连接中的阻塞管理)


    从上一次我们的分析可以看出当对等客户建立连接后,通过握手协议交换信息,这样对于每个连接都有一个Connection对象,然后有一个 SingleDownload和Upload与其对应。这一次将从握手协议完成后继续分析,然后介绍Choker,阻塞策略控制器的工作原理。


    SingleDownload在初始化时没有做什么特殊的操作,仅仅是创建了一个BadDataGuard对象和它对应。这个对象是用来统计坏数据的信息,以便确定坏的对等客户的。而Upload对象在创建的时候,如果自己已经有部分下载数据,就把自己的块拥有情况发送出去 (send_bitfield)。现在就可以来看send_bitfield,我们可以看到在Connection中定义了不少send_xxx函数用来发送某种消息,并且在Connection对象定义之前,定义了那些消息的类型的对应的常数项。另外这些send_xxx函数大都调用了 _send_message,它的作用就是在要发送的消息前面添加上它的长度(4个字节),然后发送出去,如果有必要,则放入队列中稍后发送。这样,每一次_got_message得到的就是消息的内容了。


    现在来看_got_message,它直接取第一个字节就行了,这就是消息的类型。以后我们可以再检查其它类型的消息,现在我们直接看elif t == BITFIELD这部分,得到对方的块拥有状况比特数组后,让自己的download对象记录下来,即调用 SingleDownload.got_have_bitfield()。这个函数首先检查自己是否下载完成,然后检查对方的比特数组中"假"值的个数,如果自己下载完成了且对方的比特数组中"假"值为0,则说明对方也下载完成了,而两个都在做种的对等客户之间的连接是没有意义的,可以关闭它。然后 self.have = have这一句记录下对方的块拥有情况。所以前面提到StorageWrapper保存自己的块拥有状况,而对应于每个Connection对象的 SingleDownload对象中则保留了对方的块拥有状况。然后让PiecePicker记录下别人有一块(got_have,complete记录的是自己有一块,在_SingleTorrent的代码中可以看到)。下面的这个endgame则是一种策略模式,即表示进入收尾阶段,它检查自己所有的网络请求all_requests(后面还会分析到它),如果对方有某一块(再次注意,这里self.have[piece]是对方有第piece块,而不是自己),那么发送一条消息,send_interested()。表示说我对你(所拥有的内容)感兴趣。而如果没有进入收尾阶段,则只是检查自己有那块没有而对方有,如果有的话,则send_interested()。注意send_interested()调用一次,对方知道这个意思就行了。


    看Connection._got_message中得到这个消息后怎么处理。是self.upload.got_interested()。这个函数中维持自己的interested变量为真值,然后通知choker这件事情,choker.interested则选择是否要进行一次 _rechoke()。


    现在应该注意到choked和interested这两个变量,这两个变量的值的意义分别是是否阻塞和是否感兴趣,它们对下载起到直接开关的作用。在每个 SingleDownload和Upload对象中都有这两个变量。在初始化时,choked都为真而interested都为假,这样就不会有实际的内容(即种子文件的共享资源)在流通,而要有实际的内容流通必须这两个变量的值和它们初始化时的值刚好相反才行,也就是说,只有当一方对另外一方感兴趣,而对方又没有拒绝你(choked=false)的时候,你们之间在这个方向才可能会存在实际的下载流量。另外这两个变量在网络连接的每个方向都是保持一致的,即Upload中的这两个变量和连接另外一头的SingleDownload中的这两个变量保持一致,如果有某个变量发生变化,要发送消息给对方,让对方能继续保持一致。注意这里的保持一致指的是网络连接的两头,而不是本地的Connection对象对应的SingleDownload和 Upload,即本地的Connection的SingleDownload和对等客户的Upload保持一致,而本地的Connection的 Upload和对等客户的SingleDownload保持一致,而在同一个连接中,下载和上传的两个方向有可能不一致,即一个方向阻塞了,另一个方向还在下载。


    前面已经注意到,interested这个变量的改变很容易,只要发现对方有自己没有的块,就会发送这条消息,而choked这个变量的控制就有一定的策略了。Choker就控制所有的连接(_SingleTorrent级别)的阻塞。它在初始化时即保证_round_robin每十秒种执行一次,而每次有连接进入时,用connection_made来进行登记,Choker中维护了所有连接的列表,且这个列表是故意打乱顺序的。在BT的控制策略中,我们还可以多次看到随机打乱顺序的情况发生,因为有时随机数就是最好的策略。在_round_robin中,首先检查是否已经完成,如果完成则调用 _rechoke_seed(),按照自己已经开始做种的情况进行处理。而计算count%3的余数就可以保证_round_robin执行三次这部分代码会执行一次,因为count只有在_round_robin中会被加一。这部分代码就是选择一个choked和interested同时为真的连接放到列表的开头(不要让喜欢你的人等待太久)。


    在_rechoke()中,首先选择出一些符合解除choked状态的连接(条件是interested和下载方向的is_snubbed,即当前时间是否距离上次下载到东西的时间过短),然后把所有的这些连接按照下载的速度排序,由于前面增加了一个负号,因此下载速度最块的排在前面。然后根据配置项中的最大上传数计算一个配额,这个配额不能等于最大上传数,最多只能对于这个数减一,从这个列表中取出排名前面的若干位,设置一个mask标志。下面计算出最小上传数,count。count至少要为一,如果最小上传数比前面的配额还大,那么count也相应增大。下面就是解除choke状态了,首先mask 为1的,无条件解除,如果mask不为1,但是count还大于0,那么用掉一个count,解除choke状态,其它的连接,一律choke掉。


    Upload的choke和unchoke都是在确定状态改变的情况下,开始向对方通知这一消息。


    这一次结合连接中开始的部分消息交互过程,介绍了choker这一阻塞策略管理器的工作原理。下一次将开始介绍在连接的双方的已经同意交换数据(choked为假而interested为真)时的情况。


5.6 客户端源代码分析(下载过程中的块选取策略)


    上一次介绍了对等客户之间在连接建立后的一些动作,以及BT中的阻塞控制策略。这一次将介绍当某个连接终于畅通时,双方的数据交互,也以此为基础介绍BT中另一重要的策略控制器PiecePicker。


    Choker在选择了解除一个连接的阻塞后,Upload.unchoke()将会执行,Connection对象的send_unchoke()也在此被执行。当网络的另一端收到这条消息后,它对应的SingleDownload.got_unchoke()将会开始进行处理。它再检查自己的 interested状态,如果自己也感兴趣的话,那么就用_request_more()开始向对方请求数据了。


    _request_more()可以给一个indices作为参数,这个参数是一个列表,意思就是说优先下载号码在这个列表中的块。如果这个参数为 None,那意思就是说你自己看着办吧,觉得下哪块合适就下哪块。首先检查自己的active_requests,就是当前连接中已经发出去的请求,如果已经发出去的请求太多了(而还没有数据返回),就暂时不发出新的请求了而是直接返回。下面检查endgame,如果已经进入这个阶段则按照这个阶段的方式去下载(fix_download_endgame(),收尾阶段特殊方式下载)。


    接下来就开始生成请求了,首先检查indices,如果是None,那么让PiecePicker来挑一块,否则,逐个的检查indices中的值,如果这个号码的块对方有(have[i])而自己又想要(do_I_have_requests(i)),那么就是它了。PiecePicker如何进行块的选取的策略我们稍后再分析,现在我们知道的就是它已经决定下载某一块了。然后要检查interested,如果有必要,还要通知一下对方。下面一段就是不断向StorageWrapper要网络请求的参数,new_request根据自己在硬盘上的某一块的拥有情况,不断得返回块内相对偏移和长度。在这里,我们可以看出,对等客户之间要求传输实际的数据的请求有三个参数,即第几块,块内偏移多少,长度多少。而这个长度是根据配置文件中的参数决定的,通常就是一个slice,它要能一次下载完。当然,一块的长度不一定是slice的整数倍,因此最后一个slice的长度要短一些,不过,这些细节在 StorageWrapper中已经处理好了。从StorageWrapper得到请求后,就把它加到自己的active_requests中,然后让自己的Connection对象去send_request()。现在我们也应该更加清楚active_requests和 inactive_requests的意义了,即平时StorageWrapper根据实际情况,准备好inactive_requests,然后在 SingleDownload对象中请求发出时,把它们逐渐转移到自己的active_requests中。


    在两个while循环的下面,检查active_requests,意思就是说如果经过以上的所有过程,如果active_requests还是空的,那么说明什么呢?只能说明对方根本就没有(或者说曾经有,但是现在已经没有了)自己感兴趣的数据,而如果自己还是interested的话,要调用一个 send_not_interested(),意思是我不再对你感兴趣了。下面检查lost_interests中的值,这些都是在下载过程中曾经是自己想要的,但是现在已经不想要了(主要原因是自己已经拥有了)。接下来这个for循环的意思就是检查所有的SingleDownload对象,告诉它们某一块已经有了,不用再去下了,而且有些SingleDownload要因此发出NOT_INTERESTED。最后再次检查是否进入endgame阶段,如果是,则按照这种阶段的行为进行处理。


    现在我们就可以来研究PiecePicker这个块选择策略控制器的行为了,从前面的分析我们知道,每个PiecePicker对应一个 _SingleTorrent,使用它时经历了以下几步:首先是初始化,然后根据自己已经有的块,把它告诉给 PiecePicker(complete(i)), 以后就不要从这中间选了,还有就是当一个SingleDownload对象获取对方的块拥有状况位图时,也要告诉PiecePicker(got_have(i)),意思是这一块有人有了。最后当需要PiecePicker做出选择时,只要调用其 next函数,它需要一个判断函数(_want),以及一个对方是否是种子的标志(self.have.numfalse == 0),_want函数就是这样的一个函数,当PiecePicker选了一块后,要拿给它检查,看看这一块是不是它确实想要的,如果不是的话,PiecePicker会重新选择。而_want()函数的判断标准很简单,那就是别人有而自己又想要的。


    PiecePicker的初始化工作主要是对自己的内部变量进行。这里要解释一下这些变量的作用,这样能够更加方便地对后面的部分进行理解。 numpieces,总的块数。interests是按照拥有者的数量排序的块列表的列表,就是说,它是一个列表,列表中的第0个元素是所有的自己感兴趣而没有人有的块的列表,第1个元素是所有的自己感兴趣而只有一个人有的块的列表,等。pos_in_interests,就是每一块在interests 中的对应元素所表示的列表中的位置,如果某一块比如说i,自己已经有了,那么pos_in_interests[i]的值没有意义。 numinterests的值就是某块有多少人拥有(不包括自己),以上三个变量保持这样的关系:如果一块i,自己没有,那么 interests[numinterests[i]][pos_in_interests[i]]=i。have是一个布尔数组,表示自己已经有那块,在初始化完成后,它应该和StorageWrapper中的实际情况保持一致。crosscount则是一个统计情况数组,即有多少块没有人拥有,有多少块有一个人拥有,等,自己拥有的某一块也在这里参加计数。numgot,已经得到的块数。scrambled,一个包含从0到numpieces-1的序列,但是被随机打乱了。


    现在来看PiecePicker.complete,即自己有了某块,首先have中的值要设置,然后从numinterests中查到自己原来有多少人拥有,把crosscount中对应的项减一,然后把它下一项加一,如果没有下一项,那么就在后面添加一项。由此我们可以看到,crosscount数组是逐渐增大的。然后它做的事情是把interests中的对应的项删除掉,因为它已经不在自己感兴趣的范围内了,其它几行代码是为了保持这些变量值的一致性。然后试图从started和seedstarted中删除这一块(如果没有这一块也无所谓,什么也不用做)。


    PiecePicker.got_have,处理的情况是别人有了某一块。首先还是保持crosscount的一致,然后处理interests列表。调用_shift_over把piece从interests列表中的一个元素转移到另一个元素(同时还要保持其它变量的值的意义的一致性)。 _shift_over做的事情就是从第一个列表中删除一个元素,然后将其插入第二个元素随机的位置,同时维护pos_in_interests值的意义。


    PiecePicker.requested,哪一块已经开始下载了,这个在SingleDownload中会被调用,它只是维护两个列表,started和seedstarted。


    PiecePicker.next,可以说是PiecePicker中提供的最重要的功能,选择一块进行下载。它选择的第一条原则是,已经开始下载的优先把它下载完(return choice(bests)及其前面的代码)。它检查选择的两个数组,根据对方是否是种子选择一个数组。然后在所有的这个数组中选择出自己想要的,检查它们的numinterests,即拥有此块的人数,选出拥有人数最少的块,放入bests中,如果有并列的,则添加到bests,因此在这里结束后,bests中的元素是所有正在下载的且自己想要的块中拥有人数最少的块的列表,那么就从中间随机选择一个返回即可。选择的第二条原则是,当自己拥有的块数少于一定的数量时,随机选择自己想要的块进行下载(第一阶段结束后的那个if块),因此它用到了那个scrambled列表,而当自己所拥有的块数超过一定的值(config['rarest_first_cutoff'])后,执行第三阶段的方案。选择的第三条原则是,优先选择下载拥有的人数最少的块,我们看到,它从interests中第1个元素开始检查,选择最先找到的自己想要的块,第0个元素不用检查,因为没有人拥有的块肯定下载不到。我们可以看出,它的选择原理是比较简单但是又很有效的,优先下载拥有人数最少的块就能够保证所有的块能够在最短的时间内尽可能得让更多的人拥有,换一个术语说就是能尽快提高要下载的内容的副本率。


    这一次我们分析了对等客户在下载的过程中,如何进行下载的策略控制。下一次将分析收到对方的下载请求后的处理方式等。

 

5.7 客户端源代码分析(实际数据的传输及其速率限制策略)


    上一次分析了下载过程中如何进行下载某一块的选取。这次分析在收到对方的下载请求后程序的处理行为。


    首先,仍然看Connection._got_message中收到请求消息的处理代码,即elif t == REQUEST:后面的部分。首先检查这个消息是否符合格式,它的长度必须是13(1个字节的消息类型加上3个4字节整数,分别代表块的位置,块内偏移,请求长度),以及块的位置必须小于自己拥有的总块数,然后由Upload.got_request进行处理。在Upload.got_request中,首先检查状态,如果对方还没有声明interested就或者申请的长度大于自己的max_slice_length,即一次能够发送出去的最长的数据块,那么中断连接,由此可见,在BT通信协议中,要先声明interested才可以向对方请求数据。然后在自己的Connection没有发送 choke时就可以发送数据了,但是这里发送数据它并不是直接发送数据,而是把请求保持在自己的buffer中,然后让RateLimiter把自己的 Connection加入到它的队列中。


    RateLimiter,在Multitorrent中定义,作用是对全局的速度进行限制。由于BT通信协议中,只有发送实际的数据会需要比较多的带宽,因而也只有在这种情况下会需要用RateLimiter来对其进行限制。现在我们可以注意到在每个Connection中还有一个next_upload 变量,它在其它地方都没有用到,仅仅是在这里,它的作用就是把若干个连接通过这种方式组成一个链表。next_upload的类型是 Connection,不是Upload,这里要注意。我们看到RateLimiter.queue函数中进行的就是数据结构中很常见的链表操作,其中 self.last指向了上一个Connection对象,插入新的Connection对象时,last会指向它。另外如果原来队列是空的话,那么开始 try_send,否则就不用做什么,因为try_send会检查队列,逐个从中取出连接对象,并且发送数据。try_send中首先计算 offset_amount,这个值的意义就相当于可以发送多少字节,也就是一种“配额”,它的值小于0就可以继续发送,发送了一些字节后增加相应的字节,如果大于0,那么就停下来,把发送的任务往后延一段时间。其中如果check_time标志为真的话,那就清0,以前的时间不算,重新开始计算。配额每次减少的字节数是上一次的时间(self.lasttime)和这次的时间差乘以upload_rate,这也很好理解,隔了这么些时间,又可以上传若干字节了。下面的while循环就是在配额还有的情况下,不断调用send_partial函数进行数据的发送,然后发送完毕后,检查该连接是否已经暂时没有发送需求了(即返回的实际发送的字节数是0或者连接还未刷新,即flushed),如果该连接暂时没有需求,则将其从链表中删除。但是无论它还有没有需求,接下来发送的都是链表中的下一个元素。另外,在python中允许while循环后跟一个else语句,它被执行的条件是循环正常结束,即因为 while的循环条件不满足而结束循环,而当使用break来退出循环时,这个else语句后面的内容是不会被执行的。在这里,while的结束条件是配额用完。那么意味着还有数据要下载,那么就等待一段新的时间继续执行此任务,等待多久呢?它等待的时间是刚好能把配额又降到0的时间。另外,由于直接执行可能会有一些延迟,因此,这里肯定可以保证下次运行时有上传配额。另外这个while循环中唯一的break只有在发现队列已经清空的情况下被执行到。


    Connection.send_partial,负责实际发送数据。它有个参数bytes,指定了它最多只能发送这么多数据。 _partial_message是它维护的分块消息变量,如果它不能一次把它发送出去,就把它截断,然后下次发送。首先看看它是否是空的,如果是,先从 Upload处获取一块代上传的消息(get_upload_chunk),这个函数的做法是从自己的buffer(Upload.buffer,前面提到,表示自己要上传的请求,但是当时只是把自己的连接对象加到RateLimiter的队列中)中获取一块请求,然后让 StorageWrapper.get_piece去实际得按照要求把某一块的某一部分读取出来,然后再更新一些速率统计对象的值,最后把这块数据返回。回到send_partial中,得到数据块后,把_partial_message制造出来,做成可以直接往网络上发送的那种格式。下面检查 bytes,如果这次不让发送这么多数据,则只发送开始的部分,然后截断剩余的部分。这样下次调用该连接的send_partial时就会继续发送剩下的数据。而如果可以一次发送完,则在其队列尾部增加上choke或者unchoke消息,这里,我们看到,程序保证了一部分(其实就是一个slice)如果要发送的话一定能发送完,即使阻塞控制器要求阻塞某个连接,它也只能阻止发送完一部分后再继续发送下一部分。


    好了,现在终于能够收到实际的数据了,我们继续来看Connection._got_message中的elif t == PIECE:这一段。再次提醒,如果程序执行到这里的话,收到的部分一定是完整的,因为每一条消息都是先发送了它的长度然后才是它的内容,而如果只收到部分消息的话,程序最多执行到Connection._read_messages。当收到对方的发送的数据块后,先把开始的两个整数解出来,即第几块,块内偏移多少(长度多少不用给出,因为已经有数据块的实际内容),然后做一些基本检查。检查通过后,将其交给 SingleDownload,SingleDownload.got_piece会对其进行进一步处理。如果这个函数返回真值,意思就是说这一块已经完整了,因为每一块被分成了若干个slice进行下载,因此下载到一个slice不一定能使一块完整。而如果这一块确实完整了,那么给此Encoder的所有的正常Connection都发出HAVE消息(send_have),意思就是通知所有和自己连接的对等客户,我刚刚下到了某一块,以后你们要下载这一块也可以来找我。


    现在来研究SingleDownload.got_piece,它的作用就是处理网络上到来的实际数据。首先,从自己的active_requests中试图清除掉该数据对应的请求,如果发现自己根本就没有请求那些数据,就直接丢弃它们。然后进行endgame检查以及更新一些速率测量器。接下来要注意,StorageWrapper.piece_came_in会对数据进行检查,如果它返回真并不是说明这一块数据下载完了,只是说明它没有检查出问题,而如果它返回假的值,那么后果就很严重了,说明这一块数据有问题,整块的数据都需要重新下载。这个if块内的代码做的工作就是重新分配下载任务。要调用StorageWrapper.do_I_have后才知道这个部分(slice)下载完后是不是整个的这一块也完成了,如果是则再将这一信息通知 PiecePicker(PiecePicker.complete)。下载完后要进行一些检查,确定下一步的下载策略,这些在以下的代码中可以看到。最后返回的值是自己是否已经下载完了这一块。


    现在我们已经把BT的运作原理,即对等客户之间是如何交换数据基本上分析完了,剩下的未分析的部分代码基本上可以自行阅读。

你可能感兴趣的:(网络,服务器,upload,任务,BT,代码分析)