一个Python downloader的代码分析

读了一个用Python从网上下载大量文件的代码,不长的代码里可以学习的东西非常多,现在把它写下来分析一下。

代码

代码是这样的:

#!/usr/bin/env python# -*- coding: utf-8 -*-from __future__ import (division, print_function, absolute_import,                        unicode_literals)__all__ = ["fetch"]import geventfrom gevent import monkeymonkey.patch_all()import osimport shutilimport requestsfrom itertools import productfrom tempfile import NamedTemporaryFiledata_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")filename = os.path.join(data_dir, "{year}-{month:02d}-{day:02d}-{n}.json.gz")try:    os.makedirs(data_dir)except os.error:    passurl = "http://data.githubarchive.org/{year}-{month:02d}-{day:02d}-{n}.json.gz"def fetch(year, month, day, n):    kwargs = {"year": year, "month": month, "day": day, "n": n}    local_fn = filename.format(**kwargs)    # Skip if the file exists.    if os.path.exists(local_fn):        return    # Download the remote file.    remote = url.format(**kwargs)    r = requests.get(remote)    if r.status_code == requests.codes.ok:        # Atomically write to disk.        # http://stackoverflow.com/questions/2333872/ \        #        atomic-writing-to-file-with-python        f = NamedTemporaryFile("wb", delete=False)        f.write(r.content)        f.flush()        os.fsync(f.fileno())        f.close()        shutil.move(f.name, local_fn)if __name__ == "__main__":    for year, month in product(range(2011, 2014), range(1, 13)):        jobs = [gevent.spawn(fetch, year, month, day, n)                for n, day in product(range(1, 32), range(24))]        gevent.joinall(jobs)        print("Finished {0}-{1}".format(year, month))

概述

这段代码实现的功能是,从远程下载大量的json文件,并保存到本地。

运行它需要额外安装的库有:requests,gevent。这些库可以通过pip安装,开头的这个monkey.patch_all()后面会有介绍。

shutil这个库名字是由“shell”与“util”合成,提供了一些较高层次上的文件和文件集合操作,但是会丢失文件的一些metadata,比如用户和用户组。其他的诸如itertools、tempfile功能就不言而喻了。

在代码的开始,首先构造了用于存放将要下载的文件的目录的路径字符串,使用了os.path中的一些方法。__file__变量存放当前脚本路径(可能是一个相对路径,取决于在命令行中执行该脚本时按何种路径执行)。接着构造了将要下载的文件的绝对路径(包括文件名),起一个模板的作用,为下面调用format()做准备。

接着是创建文件夹并构造下载目标的url模板字符串。

脚本结构清晰,有一个函数fetch(),通过执行这个函数来完成远程文件的下载与保存。

fetch()

这是脚本中干活的函数,有以下几个点需要我们掌握。

*args**kwargs

在Python函数定义中,我们经常会发现函数带有如下形式的参数:

def foo(arg, *args, **kwargs):pass

上例中函数foo()的后两个参数没有显式定义,类似于允许传递不定数量参数的函数,但比那要灵活很多。在调用时,与*args对应的参数将会打包成一个元组传递给函数;**kwargs应为函数定义中最后一个形参,对应的参数必须以arg=val的形式传递,所有这样的参数会打包成字典传给函数。

还有一种传递方式是直接用*('baz', 123)**{'bar':12, 34:56}这样的形式直接传递。还有很多灵活的用法,请自行搜索。

回到本脚本上来,str.format(*args, **kwargs),这是str类型里面的format方法的定义,本脚本就是直接将一个参数字典传递给format函数。

Requests: HTTP for Humans

requests这个库比较给力,作者认为python自带的urllib2虽然提供了很多方法来让程序员完成日常所需的一些http需求,但是那些方法比较凌乱,要兼容不同时代的不同网络,在使用时要做很多多余的工作。Python不应该是这样的啊!于是requests诞生了,有很多exciting features。

脚本中首先构造了欲下载文件实际的URL,之后用requests库中优雅的的get方法来下载文件,不得不承认,requests库真的非常优雅,一行代码,读者可以尝试使用urllib2来完成同样的功能。

这里还有一个requests与urllib2实现登录远程服务器的一个代码的对比。

requests库可以让程序与web无缝结合,用起来很顺手,下次在写跟web有关的函数时,可以试试这个,用户反响很好。

原子性地写入文件(Atomically write to disk)

脚本中,当下载成功后,要将下载到的文件写入磁盘。作者为了保证数据的有效性与正确性,采用了原子性地将文件写入。基本思路是,将数据写入一个临时文件,待确保数据写入成功后,将临时文件改名为目标文件名。注意的是,更名操作仅仅在源文件和目标文件在同一个文件系统的情况下是原子操作,而且,源文件和目标文件的名称必须不相同。更多的细节请参考脚本注释中给出的SO讨论链接。

file.flush()os.fsync()

为了确保数据一定被写入了磁盘,在file.flush()后面又补加了一句os.fsync()。这是为什么呢?在这个过程中涉及到两个层次的buffer:

1. 内部的buffer(Internal buffers)2. 操作系统的buffer(Operating system buffers)

所谓Internal buffers是由程序创建的buffer,例如runtime、lib、language,旨在避免频繁地进行系统调用来将数据写入磁盘,以提高效率。也即每次向文件对象写入数据时,实际上是写入了这个buffer,什么时候这个buffer满了,数据才真正调用系统调用写入实际的文件。

然而,由于操作系统还有一层buffer,上述操作也并不意味着就一定将数据写入了实实在在的文件,仅仅意味着数据从由程序运行时维护的buffer转移到了操作系统的那层buffer。

设想一下这种情况,当数据在操作系统的buffer中时,突然机器断电了,数据就丢了。

为了解决这种情况,flushfsync就诞生了。

首先,flush将逗留在Internal buffer的数据写入实际的文件,注意,假设此时有另外一个进程打开了这个文件进行读取,是可以读到被flush的内容的,但是这不意味着数据被永久存入了磁盘。

接下来,我们调用fsync来把存储在Operating system buffer中的数据同步到实实在在的硬盘中去。

其实,我们在编写程序的过程中没有必要纠结于此。调用flush,剩下的交给OS即可。但是万一要求数据必须稳妥地存入外存,或者你有强迫症,就在flush后面加一个fsync吧。注意这肯定会对效率与性能有所影响。具体可以参考这里。

__main__itertools.product()

这个函数接受两个列表作为参数,返回一个由两个列表的笛卡尔乘积作为元素的迭代器。

gevent

gevent这个库更是牛B到爆。它是一个基于Python协程的网络库,官网的第一句话简明地介绍了它:

gevent is a coroutine-based Python networking library that uses greenlet to provide a high-level synchronous API on top of the libevent event loop.

翻译不好,于是直接把这搬上来了,主要是对greenlet和libevent都不熟悉。

协程(Co-routine)是什么呢?简单来说,就是可以暂时中断,之后再继续执行的程序。想到什么了?对,就是Python的generater、yield,还有range()xrange()。协程跟这些有着千丝万缕的联系。

这里有一篇很好的文章来讲co-routine和gevent。这里也是一篇好文,讲解gevent的优点和缺点。

在理解了协程的过程之后,我认为协程跟多线程最大的区别就在于,协程之间的切换相对比thread之间的context-switch来说,成本很小;而且,thread的context-switch虽然我们可以进行某种程度的控制,但是大部分还是得靠OS来决定要先排程哪个thread,而coroutine的执行完全是由我们自己控制的。也就是说,协程们是在用户态由程序员控制进行排程,协程其实是在一个线程中折腾的。

回到脚本上来,从远程下载文件保存到本地,这其中要做大量的IO操作。脚本是采用了gevent库利用协程进行高效地下载。其中joinall()是等待所有协程都return之后,主程序继续向下执行。

再去看脚本开头的monkey.patch_all(),这是将Python内建的函数库中一些同步IO操作取代成gevent的异步IO操作,这样,当IO阻塞发生时,会切到主线程进行协程的排程,而非阻塞在那里。

想了解更多,可以去阅读greeelet和gevent的源码。

总结

实不相瞒,我也写了代码做同样的工作。但是,我用了shell的wget。看了这个downloader,真的除了膜拜别无他求。如果让我用Python写一个downloader,我顶多会用多线程(可能嫌麻烦,连多线程也不用,求喷)?会考虑写入文件的原子性吗?会去主动搜索一下能提高效率的lib吗?

在读了这段代码之后,真心觉得知也无涯啊。

读了一个用Python从网上下载大量文件的代码,不长的代码里可以学习的东西非常多,现在把它写下来分析一下。

一个Python downloader的代码分析

相关文章:

你感兴趣的文章:

标签云: