Python爬虫开发与项目实战
上QQ阅读APP看书,第一时间看更新

第1章 回顾Python编程

本书所要讲解的爬虫技术是基于Python语言进行开发的,拥有Python编程能力对于本书的学习是至关重要的,因此本章的目标是帮助之前接触过Python语言的读者回顾一下Python编程中的内容,尤其是与爬虫技术相关的内容。

1.1 安装Python

Python是跨平台语言,它可以运行在Windows、Mac和各种Linux/Unix系统上。在Windows上编写的程序,可以在Mac和Linux上正常运行。Python是一种面向对象、解释型计算机程序设计语言,需要Python解释器进行解释运行。目前,Python有两个版本,一个是2.x版,一个是3.x版,这两个版本是不兼容的。现在Python的整体方向是朝着3.x发展的,但是在发展过程中,大量针对2.x版本的代码都需要修改才能运行,导致现在许多第三方库无法在3.x版本上直接使用,因此现在大部分的云服务器默认的Python版本依然是2.x版。考虑到上述原因,本书采用的Python版本为2.x,确切地说是2.7版本。

1.1.1 Windows上安装Python

首先,从Python的官方网站www.python.org下载最新的2.7.12版本,地址是https://www.python.org/ftp/python/2.7.12/python-2.7.12.msi。然后,运行下载的MSI安装包,在选择安装组件时,勾选上所有的组件,如图1-1所示。

图1-1 Python安装界面

特别要注意勾选pip和Add python.exe to Path,然后一路点击Next即可完成安装。

pip是Python安装扩展模块的工具,通常会用pip下载扩展模块的源代码并编译安装。

Add python.exe to Path是将Python添加到Windows环境中。

安装完成后,打开命令提示窗口,输入python后出现如图1-2情况,说明Python安装成功。

图1-2 Python命令行窗口

当看到提示符“>>>”就表示我们已经在Python交互式环境中了,可以输入任何Python代码,回车后会立刻得到执行结果。现在,输入exit()并回车,就可以退出Python交互式环境。

1.1.2 Ubuntu上的Python

本书采用Ubuntu 16.04版本,系统自带了Python 2.7.11的环境,如图1-3所示,所以不需要额外进行安装。

图1-3 Python环境

拥有了Python环境,但为了以后方便安装扩展模块,还需要安装python-pip和python-dev,在shell中执行:sudo apt-get install python-pip python-dev即可安装,如图1-4所示。

图1-4 安装pip和python-dev

1.2 搭建开发环境

俗话说:“工欲善其事必先利其器”,在做Python爬虫开发之前,一个好的IDE将会使编程效率得到大幅度提高。下面主要介绍两种IDE:Eclipse和PyCharm,并以在Windows 7上安装为例进行介绍。

1.2.1 Eclipse+PyDev

Eclipse是一个强大的编辑器,并通过插件的方式不断拓展功能。Eclipse比较常见的功能是编写Java程序,但是通过扩展PyDev插件,Eclipse就具有了编写Python程序的功能。所以本书搭建的开发环境是Eclipset+PyDev。

Eclipse是运行在Java虚拟机上的,所以要先安装Java环境。

第一步,安装Java环境。Java JDK的下载地址为:http://www.oracle.com/technetwork/java/javase/downloads/index.html。下载页面如图1-5所示。

图1-5 JDK下载界面

下载好JDK之后,双击进行安装,一直点击“下一步”即可完成安装,安装界面如图1-6所示。

图1-6 JDK安装界面

安装完JDK,需要配置Java环境变量。

1)首先右键“我的电脑”,选择“属性”,如图1-7所示。

图1-7 电脑属性

2)接着在出现的对话框中选择“高级系统设置”,如图1-8所示。

图1-8 高级系统设置

3)在出现的对话框中选择“环境变量”,如图1-9所示。

图1-9 环境变量

4)新建名为classpath的变量名,变量的值可以设置为:.; %JAVA_HOME\lib; %JAVA_HOME\lib\tools.jar,如图1-10所示。

图1-10 classpath环境变量

5)新建名为JAVA_HOME的变量名,变量的值为之前安装的JDK路径位置,默认是C:\Program Files\Java\jdk1.8.0_101\,如图1-11所示。

图1-11 JAVA_HOME环境变量

6)在已有的系统变量path的变量值中加上:; %JAVA_HOME%\bin; %JAVA_HOME%\jre\bin,如图1-12所示,自此配置完成。

图1-12 path环境变量

下面检验是否配置成功,运行cmd命令,在出现的对话框中输入“java-version”命令,如果出现图1-13的结果,则表明配置成功。

图1-13 java-version

第二步,下载Eclipse,下载地址为:http://www.eclipse.org/downloads/eclipse-packages/,下载完后,解压就可以直接使用,Eclipse不需要安装。下载界面如图1-14所示。

图1-14 下载界面

第三步,在Eclipse中安装pydev插件。启动Eclipse,点击Help->Install New Software...,如图1-15所示。

图1-15 安装新软件

在弹出的对话框中,点击Add按钮。在Name中填:Pydev,在Location中填http://pydev.org/updates,然后一步一步安装下去。过程如图1-16和图1-17所示。

图1-16 安装过程1

图1-17 安装过程2

第四步,安装完pydev插件后,需要配置pydev解释器。在Eclipse菜单栏中,点击Windows→Preferences。在对话框中,点击PyDev→Interpreter-Python。点击New按钮,选择python.exe的路径,打开后显示出一个包含很多复选框的窗口,点击OK即可,如图1-18所示。

图1-18 配置PyDev

经过上述四个步骤,Eclipse就可以进行Python开发了。如需创建一个新的项目,选择File→New→Projects...,再选择PyDev→PyDevProject并输入项目名称,点击Finish即可完成项目的创建,如图1-19所示。

图1-19 新建Python工程

然后新建PyDev Package,就可以写代码了,如图1-20所示。

图1-20 新建Python包

1.2.2 PyCharm

PyCharm是本人用过的Python编辑器中,比较顺手,而且可以跨平台,在MacOS、Linux和Windows下都可以用。PyCharm主要分为专业版和社区版,两者的区别在于专业版一开始有30天的试用期,之后就要收费;社区版一直免费,当然专业版的功能更加强大。我们进行Python爬虫开发,社区版基本上可以满足需要,所以接下来就以社区版为例。大家可以根据自己的系统版本,进行下载安装,下载地址为:http://www.jetbrains.com/pycharm/download/#。下载界面如图1-21所示。

图1-21 下载界面

以Windows为例,下载后双击进行安装,一步一步点击Next,即可完成安装。安装界面如图1-22所示。

图1-22 安装界面

安装完成后,运行PyCharm,创建Python项目就可以进行Python开发了,如图1-23所示。

图1-23 创建项目开发

1.3 IO编程

IO在计算机中指的是Input/Output,也就是输入输出。凡是用到数据交换的地方,都会涉及IO编程,例如磁盘、网络的数据传输。在IO编程中,Stream(流)是一种重要的概念,分为输入流(Input Stream)和输出流(Output Stream)。我们可以把流理解为一个水管,数据相当于水管中的水,但是只能单向流动,所以数据传输过程中需要架设两个水管,一个负责输入,一个负责输出,这样读写就可以实现同步。本节主要讲解磁盘IO操作,网络IO操作放到之后的1.5节进行讨论。

1.3.1 文件读写

1.打开文件

读写文件是最常见的IO操作。Python内置了读写文件的函数,方便了文件的IO操作。

文件读写之前需要打开文件,确定文件的读写模式。open函数用来打开文件,语法如下:

open(name[.mode[.buffering]])

open函数使用一个文件名作为唯一的强制参数,然后返回一个文件对象。模式(mode)和缓冲区(buffering)参数都是可选的,默认模式是读模式,默认缓冲区是无。

假设有个名为qiye.txt的文本文件,其存储路径是c:\text(或者是在Linux下的~/text),那么可以像下面这样打开文件。在交互式环境的提示符“>>>”下,输入如下内容:

>>> f = open(r'c:\text\qiye.txt')

如果文件不存在,将会看到一个类似下面的异常回溯:

Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
IOError: [Errno 2] No such file or directory: 'C:\\qiye.txt'

2.文件模式

下面主要说一下open函数中的mode参数(如表1-1所示),通过改变mode参数可以实现对文件的不同操作。

表1-1 open函数中的mode参数

这里主要是提醒一下’b’参数的使用,一般处理文本文件时,是用不到’b’参数的,但处理一些其他类型的文件(二进制文件),比如mp3音乐或者图像,那么应该在模式参数中增加’b',这在爬虫中处理媒体文件很常用。参数’rb’可以用来读取一个二进制文件。

3.文件缓冲区

open函数中第三个可选参数buffering控制着文件的缓冲。如果参数是0, I/O操作就是无缓冲的,直接将数据写到硬盘上;如果参数是1, I/O操作就是有缓冲的,数据先写到内存里,只有使用flush函数或者close函数才会将数据更新到硬盘;如果参数为大于1的数字则代表缓冲区的大小(单位是字节), -1(或者是任何负数)代表使用默认缓冲区的大小。

4.文件读取

文件读取主要是分为按字节读取和按行进行读取,经常用到的方法有read()、readlines()、close()。

在“>>>”输入f = open(r'c:\text\qiye.txt')后,如果成功打开文本文件,接下来调用read()方法则可以一次性将文件内容全部读到内存中,最后返回的是str类型的对象:

>>> f.read()
"qiye"

最后一步调用close(),可以关闭对文件的引用。文件使用完毕后必须关闭,因为文件对象会占用操作系统资源,影响系统的IO操作。

>>> f.close()

由于文件操作可能会出现IO异常,一旦出现IO异常,后面的close()方法就不会调用。所以为了保证程序的健壮性,我们需要使用try ... finally来实现。

try:
    f = open(r'c:\text\qiye.txt', 'r')
    print f.read()
finally:
    if f:
        f.close()

上面的代码略长,Python提供了一种简单的写法,使用with语句来替代try ... finally代码块和close()方法,如下所示:

with open(r'c:\text\qiye.txt', 'r') as fileReader:
    print fileReader.read()

调用read()一次将文件内容读到内存,但是如果文件过大,将会出现内存不足的问题。一般对于大文件,可以反复调用read(size)方法,一次最多读取size个字节。如果文件是文本文件,Python提供了更加合理的做法,调用readline()可以每次读取一行内容,调用readlines()一次读取所有内容并按行返回列表。大家可以根据自己的具体需求采取不同的读取方式,例如小文件可以直接采取read()方法读到内存,大文件更加安全的方式是连续调用read(size),而对于配置文件等文本文件,使用readline()方法更加合理。

将上面的代码进行修改,采用readline()的方式实现如下所示:

with open(r'c:\text\qiye.txt', 'r') as fileReader:
    for line in fileReader.readlines():
        print line.strip()

5.文件写入

写文件和读文件是一样的,唯一的区别是在调用open方法时,传入标识符’w’或者’wb'表示写入文本文件或者写入二进制文件,示例如下:

f = open(r'c:\text\qiye.txt', 'w')
f.write('qiye')
f.close()

我们可以反复调用write()方法写入文件,最后必须使用close()方法来关闭文件。使用write()方法的时候,操作系统不是立即将数据写入文件中的,而是先写入内存中缓存起来,等到空闲时候再写入文件中,最后使用close()方法就将数据完整地写入文件中了。当然也可以使用f.flush()方法,不断将数据立即写入文件中,最后使用close()方法来关闭文件。和读文件同样道理,文件操作中可能会出现IO异常,所以还是推荐使用with语句:

with open(r'c:\text\qiye.txt', 'w') as fileWriter:
    fileWriter.write('qiye')

1.3.2 操作文件和目录

在Python中对文件和目录的操作经常用到os模块和shutil模块。接下来主要介绍一些操作文件和目录的常用方法:

□ 获得当前Python脚本工作的目录路径:os.getcwd()。

□ 返回指定目录下的所有文件和目录名:os.listdir()。例如返回C盘下的文件:os.listdir("C:\\")

□ 删除一个文件:os.remove(filepath)。

□ 删除多个空目录:os.removedirs(r"d:\python")。

□ 检验给出的路径是否是一个文件:os.path.isfile(filepath)。

□ 检验给出的路径是否是一个目录:os.path.isdir(filepath)。

□ 判断是否是绝对路径:os.path.isabs()。

□ 检验路径是否真的存在:os.path.exists()。例如检测D盘下是否有Python文件夹:os.path.exists(r"d:\python")

□ 分离一个路径的目录名和文件名:os.path.split()。例如:os.path.split(r"/home/qiye/qiye.txt"),返回结果是一个元组:('/home/qiye', 'qiye.txt')。

□ 分离扩展名:os.path.splitext()。例如os.path.splitext(r"/home/qiye/qiye.txt"),返回结果是一个元组:('/home/qiye/qiye', '.txt')。

□ 获取路径名:os.path.dirname(filetpah)。

□ 获取文件名:os.path.basename(filepath)。

□ 读取和设置环境变量:os.getenv()与os.putenv()。

□ 给出当前平台使用的行终止符:os.linesep。Windows使用’\r\n', Linux使用’\n’而Mac使用’\r'。

□ 指示你正在使用的平台:os.name。对于Windows,它是’nt',而对于Linux/Unix用户,它是’posix'。

□ 重命名文件或者目录:os.rename(old, new)。

□ 创建多级目录:os.makedirs(r"c:\python\test")。

□ 创建单个目录:os.mkdir("test")。

□ 获取文件属性:os.stat(file)。

□ 修改文件权限与时间戳:os.chmod(file)。

□ 获取文件大小:os.path.getsize(filename)。

□ 复制文件夹:shutil.copytree("olddir", "newdir")。olddir和newdir都只能是目录,且newdir必须不存在。

□ 复制文件:shutil.copyfile("oldfile", "newfile"), oldfile和newfile都只能是文件;shutil. copy("oldfile", "newfile"), oldfile只能是文件,newfile可以是文件,也可以是目标目录。

□ 移动文件(目录):shutil.move("oldpos", "newpos")。

□ 删除目录:os.rmdir("dir"),只能删除空目录;shutil.rmtree("dir"),空目录、有内容的目录都可以删。

1.3.3 序列化操作

对象的序列化在很多高级编程语言中都有相应的实现,Python也不例外。程序运行时,所有的变量都是在内存中的,例如在程序中声明一个dict对象,里面存储着爬取的页面的链接、页面的标题、页面的摘要等信息:

d = dict(url='index.html', title=’首页’, content=’首页’)

在程序运行的过程中爬取的页面的链接会不断变化,比如把url改成了second.html,但是程序一结束或意外中断,程序中的内存变量都会被操作系统进行回收。如果没有把修改过的url存储起来,下次运行程序的时候,url被初始化为index.html,又是从首页开始,这是我们不愿意看到的。所以把内存中的变量变成可存储或可传输的过程,就是序列化。

将内存中的变量序列化之后,可以把序列化后的内容写入磁盘,或者通过网络传输到别的机器上,实现程序状态的保存和共享。反过来,把变量内容从序列化的对象重新读取到内存,称为反序列化。

在Python中提供了两个模块:cPickle和pickle来实现序列化,前者是由C语言编写的,效率比后者高很多,但是两个模块的功能是一样的。一般编写程序的时候,采取的方案是先导入cPickle模块,如果此模块不存在,再导入pickle模块。示例如下:

try:
    import cPickle as pickle
except ImportError:
    import pickle

pickle实现序列化主要使用的是dumps方法或dump方法。dumps方法可以将任意对象序列化成一个str,然后可以将这个str写入文件进行保存。在Python Shell中示例如下:

>>> import cPickle as pickle
>>> d = dict(url='index.html', title=’首页’, content=’首页’)
>>> pickle.dumps(d)
"(dp1\nS'content'\np2\nS'\\xca\\xd7\\xd2\\xb3'\np3\nsS'url'\np4\nS'index.html'\n
p5\nsS'title'\np6\ng3\ns."

如果使用dump方法,可以将序列化后的对象直接写入文件中:

>>> f=open(r'D:\dump.txt', 'wb')
>>> pickle.dump(d, f)
>>> f.close()

pickle实现反序列化使用的是loads方法或load方法。把序列化后的文件从磁盘上读取为一个str,然后使用loads方法将这个str反序列化为对象,或者直接使用load方法将文件直接反序列化为对象,如下所示:

>>> f=open(r'D:\dump.txt', 'rb')
>>> d=pickle.load(f)
>>> f.close()
>>> d
{'content': '\xca\xd7\xd2\xb3', 'url': 'index.html', 'title': '\xca\xd7\xd2\xb3'}

通过反序列化,存储为文件的dict对象,又重新恢复出来,但是这个变量和原变量没有什么关系,只是内容一样。以上就是序列化操作的整个过程。

假如我们想在不同的编程语言之间传递对象,把对象序列化为标准格式是关键,例如XML,但是现在更加流行的是序列化为JSON格式,既可以被所有的编程语言读取解析,也可以方便地存储到磁盘或者通过网络传输。对于JSON的操作,将在第5章进行讲解。

1.4 进程和线程

在爬虫开发中,进程和线程的概念是非常重要的。提高爬虫的工作效率,打造分布式爬虫,都离不开进程和线程的身影。本节将从多进程、多线程、协程和分布式进程等四个方面,帮助大家回顾Python语言中进程和线程中的常用操作,以便在接下来的爬虫开发中灵活运用进程和线程。

1.4.1 多进程

Python实现多进程的方式主要有两种,一种方法是使用os模块中的fork方法,另一种方法是使用multiprocessing模块。这两种方法的区别在于前者仅适用于Unix/Linux操作系统,对Windows不支持,后者则是跨平台的实现方式。由于现在很多爬虫程序都是运行在Unix/Linux操作系统上,所以本节对两种方式都进行讲解。

1.使用os模块中的fork方式实现多进程

Python的os模块封装了常见的系统调用,其中就有fork方法。fork方法来自于Unix/Linux操作系统中提供的一个fork系统调用,这个方法非常特殊。普通的方法都是调用一次,返回一次,而fork方法是调用一次,返回两次,原因在于操作系统将当前进程(父进程)复制出一份进程(子进程),这两个进程几乎完全相同,于是fork方法分别在父进程和子进程中返回。子进程中永远返回0,父进程中返回的是子进程的ID。下面举个例子,对Python使用fork方法创建进程进行讲解。其中os模块中的getpid方法用于获取当前进程的ID, getppid方法用于获取父进程的ID。代码如下:

import os
if __name__ == '__main__':
    print 'current Process (%s) start ...'%(os.getpid())
    pid = os.fork()
    if pid < 0:
        print 'error in fork'
    elif pid == 0:
        print 'I am child process(%s) and my parent process is (%s)', (os.getpid(),
              os.getppid())
    else:
        print 'I(%s) created a chlid process (%s).', (os.getpid(), pid)

运行结果如下:

current Process (3052) start ...
I(3052) created a chlid process (3053).
I am child process(3053) and my parent process is (3052)

2.使用multiprocessing模块创建多进程

multiprocessing模块提供了一个Process类来描述一个进程对象。创建子进程时,只需要传入一个执行函数和函数的参数,即可完成一个Process实例的创建,用start()方法启动进程,用join()方法实现进程间的同步。下面通过一个例子来演示创建多进程的流程,代码如下:

import os
from multiprocessing import Process
# 子进程要执行的代码
def run_proc(name):
    print 'Child process %s (%s) Running...' % (name, os.getpid())
if __name__ == '__main__':
    print 'Parent process %s.' % os.getpid()
    for i in range(5):
        p = Process(target=run_proc, args=(str(i), ))
        print 'Process will start.'
        p.start()
    p.join()
    print 'Process end.'

运行结果如下:

Parent process 2392.
Process will start.
Process will start.
Process will start.
Process will start.
Process will start.
Child process 2 (10748) Running...
Child process 0 (5324) Running...
Child process 1 (3196) Running...
Child process 3 (4680) Running...
Child process 4 (10696) Running...
Process end.

以上介绍了创建进程的两种方法,但是要启动大量的子进程,使用进程池批量创建子进程的方式更加常见,因为当被操作对象数目不大时,可以直接利用multiprocessing中的Process动态生成多个进程,如果是上百个、上千个目标,手动去限制进程数量却又太过繁琐,这时候进程池Pool发挥作用的时候就到了。

3.multiprocessing模块提供了一个Pool类来代表进程池对象

Pool可以提供指定数量的进程供用户调用,默认大小是CPU的核数。当有新的请求提交到Pool中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程来处理它。下面通过一个例子来演示进程池的工作流程,代码如下:

from multiprocessing import Pool
import os, time, random
def run_task(name):
    print 'Task %s (pid = %s) is running...' % (name, os.getpid())
    time.sleep(random.random() * 3)
    print 'Task %s end.' % name
if __name__=='__main__':
    print 'Current process %s.' % os.getpid()
    p = Pool(processes=3)
    for i in range(5):
        p.apply_async(run_task, args=(i, ))
    print 'Waiting for all subprocesses done...'
    p.close()
    p.join()
    print 'All subprocesses done.'

运行结果如下:

Current process 9176.
Waiting for all subprocesses done...
Task 0 (pid = 11012) is running...
Task 1 (pid = 12464) is running...
Task 2 (pid = 11260) is running...
Task 2 end.
Task 3 (pid = 11260) is running...
Task 0 end.
Task 4 (pid = 11012) is running...
Task 1 end.
Task 3 end.
Task 4 end.
All subprocesses done.

上述程序先创建了容量为3的进程池,依次向进程池中添加了5个任务。从运行结果中可以看到虽然添加了5个任务,但是一开始只运行了3个,而且每次最多运行3个进程。当一个任务结束了,新的任务依次添加进来,任务执行使用的进程依然是原来的进程,这一点通过进程的pid就可以看出来。

注意

Pool对象调用join()方法会等待所有子进程执行完毕,调用join()之前必须先调用close(),调用close()之后就不能继续添加新的Process了。

4.进程间通信

假如创建了大量的进程,那进程间通信是必不可少的。Python提供了多种进程间通信的方式,例如Queue、Pipe、Value+Array等。本节主要讲解Queue和Pipe这两种方式。Queue和Pipe的区别在于Pipe常用来在两个进程间通信,Queue用来在多个进程间实现通信。

首先讲解一下Queue通信方式。Queue是多进程安全的队列,可以使用Queue实现多进程之间的数据传递。有两个方法:Put和Get可以进行Queue操作:

□ Put方法用以插入数据到队列中,它还有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且timeout为正值,该方法会阻塞timeout指定的时间,直到该队列有剩余的空间。如果超时,会抛出Queue.Full异常。如果blocked为False,但该Queue已满,会立即抛出Queue.Full异常。

□ Get方法可以从队列读取并且删除一个元素。同样,Get方法有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且timeout为正值,那么在等待时间内没有取到任何元素,会抛出Queue.Empty异常。如果blocked为False,分两种情况:如果Queue有一个值可用,则立即返回该值;否则,如果队列为空,则立即抛出Queue.Empty异常。

下面通过一个例子进行说明:在父进程中创建三个子进程,两个子进程往Queue中写入数据,一个子进程从Queue中读取数据。程序示例如下:

from multiprocessing import Process, Queue
import os, time, random

# 写数据进程执行的代码:
def proc_write(q, urls):
    print('Process(%s) is writing...' % os.getpid())
    for url in urls:
        q.put(url)
        print('Put %s to queue...' % url)
        time.sleep(random.random())

# 读数据进程执行的代码:
def proc_read(q):
    print('Process(%s) is reading...' % os.getpid())
    while True:
        url = q.get(True)
        print('Get %s from queue.' % url)

if __name__=='__main__':
    # 父进程创建Queue,并传给各个子进程:
    q = Queue()
    proc_writer1 = Process(target=proc_write, args=(q, ['url_1', 'url_2', 'url_3']))
    proc_writer2 = Process(target=proc_write, args=(q, ['url_4', 'url_5', 'url_6']))
    proc_reader = Process(target=proc_read, args=(q, ))
    # 启动子进程proc_writer,写入:
    proc_writer1.start()
    proc_writer2.start()
    # 启动子进程proc_reader,读取:
    proc_reader.start()
    # 等待proc_writer结束:
    proc_writer1.join()
    proc_writer2.join()
    # proc_reader进程里是死循环,无法等待其结束,只能强行终止:
    proc_reader.terminate()

运行结果如下:

Process(9968) is writing...
Process(9512) is writing...
Put url_1 to queue...
Put url_4 to queue...
Process(1124) is reading...
Get url_1 from queue.
Get url_4 from queue.
Put url_5 to queue...
Get url_5 from queue.
Put url_2 to queue...
Get url_2 from queue.
Put url_6 to queue...
Get url_6 from queue.
Put url_3 to queue...
Get url_3 from queue.

最后介绍一下Pipe的通信机制,Pipe常用来在两个进程间进行通信,两个进程分别位于管道的两端。

Pipe方法返回(conn1, conn2)代表一个管道的两个端。Pipe方法有duplex参数,如果duplex参数为True(默认值),那么这个管道是全双工模式,也就是说conn1和conn2均可收发。若duplex为False, conn1只负责接收消息,conn2只负责发送消息。send和recv方法分别是发送和接收消息的方法。例如,在全双工模式下,可以调用conn1.send发送消息,conn1.recv接收消息。如果没有消息可接收,recv方法会一直阻塞。如果管道已经被关闭,那么recv方法会抛出EOFError。

下面通过一个例子进行说明:创建两个进程,一个子进程通过Pipe发送数据,一个子进程通过Pipe接收数据。程序示例如下:

import multiprocessing
import random
import time, os

def proc_send(pipe, urls):
    for url in urls:
        print "Process(%s) send: %s" %(os.getpid(), url)
        pipe.send(url)
        time.sleep(random.random())

def proc_recv(pipe):
    while True:
        print "Process(%s) rev:%s" %(os.getpid(), pipe.recv())
        time.sleep(random.random())

if __name__ == "__main__":
    pipe = multiprocessing.Pipe()
    p1 = multiprocessing.Process(target=proc_send, args=(pipe[0], ['url_'+str(i)
          for i in range(10) ]))
    p2 = multiprocessing.Process(target=proc_recv, args=(pipe[1], ))
    p1.start()
    p2.start()
    p1.join()
    p2.join()

运行结果如下:

Process(10448) send: url_0
Process(5832) rev:url_0
Process(10448) send: url_1
Process(5832) rev:url_1
Process(10448) send: url_2
Process(5832) rev:url_2
Process(10448) send: url_3
Process(10448) send: url_4
Process(5832) rev:url_3
Process(10448) send: url_5
Process(10448) send: url_6
Process(5832) rev:url_4
Process(5832) rev:url_5
Process(10448) send: url_7
Process(10448) send: url_8
Process(5832) rev:url_6
Process(5832) rev:url_7
Process(10448) send: url_9
Process(5832) rev:url_8
Process(5832) rev:url_9

注意

以上多进程程序运行结果的打印顺序在不同的系统和硬件条件下略有不同。

1.4.2 多线程

多线程类似于同时执行多个不同程序,多线程运行有如下优点:

□ 可以把运行时间长的任务放到后台去处理。

□ 用户界面可以更加吸引人,比如用户点击了一个按钮去触发某些事件的处理,可以弹出一个进度条来显示处理的进度。

□ 程序的运行速度可能加快。

□ 在一些需要等待的任务实现上,如用户输入、文件读写和网络收发数据等,线程就比较有用了。在这种情况下我们可以释放一些珍贵的资源,如内存占用等。

Python的标准库提供了两个模块:thread和threading, thread是低级模块,threading是高级模块,对thread进行了封装。绝大多数情况下,我们只需要使用threading这个高级模块。

1.用threading模块创建多线程

threading模块一般通过两种方式创建多线程:第一种方式是把一个函数传入并创建Thread实例,然后调用start方法开始执行;第二种方式是直接从threading.Thread继承并创建线程类,然后重写__init__方法和run方法。

首先介绍第一种方法,通过一个简单例子演示创建多线程的流程,程序如下:

import random
import time, threading
# 新线程执行的代码:
def thread_run(urls):
    print 'Current %s is running...' % threading.current_thread().name
    for url in urls:
        print '%s ---->>> %s' % (threading.current_thread().name, url)
        time.sleep(random.random())
    print '%s ended.' % threading.current_thread().name

print '%s is running...' % threading.current_thread().name
t1 = threading.Thread(target=thread_run, name='Thread_1', args=(['url_1', 'url_2', '
    url_3'], ))
t2 = threading.Thread(target=thread_run, name='Thread_2', args=(['url_4', 'url_5', '
    url_6'], ))
t1.start()
t2.start()
t1.join()
t2.join()
print '%s ended.' % threading.current_thread().name

运行结果如下:

MainThread is running...
Current Thread_1 is running...
Thread_1---->>> url_1
Current Thread_2 is running...
Thread_2---->>> url_4
Thread_1---->>> url_2
Thread_2---->>> url_5
Thread_2---->>> url_6
Thread_1---->>> url_3
Thread_1 ended.
Thread_2 ended.
MainThread ended.

第二种方式从threading.Thread继承创建线程类,下面将方法一的程序进行重写,程序如下:

import random
import threading
import time
class myThread(threading.Thread):
    def __init__(self, name, urls):
        threading.Thread.__init__(self, name=name)
        self.urls = urls

    def run(self):
        print 'Current %s is running...' % threading.current_thread().name
        for url in self.urls:
              print '%s ---->>> %s' % (threading.current_thread().name, url)
              time.sleep(random.random())
        print '%s ended.' % threading.current_thread().name
print '%s is running...' % threading.current_thread().name
t1 = myThread(name='Thread_1', urls=['url_1', 'url_2', 'url_3'])
t2 = myThread(name='Thread_2', urls=['url_4', 'url_5', 'url_6'])
t1.start()
t2.start()
t1.join()
t2.join()
print '%s ended.' % threading.current_thread().name

运行结果如下:

MainThread is running...
Current Thread_1 is running...
Thread_1---->>> url_1
Current Thread_2 is running...
Thread_2---->>> url_4
Thread_2---->>> url_5
Thread_1---->>> url_2
Thread_1---->>> url_3
Thread_2---->>> url_6
Thread_2 ended.
Thread_1 ended.

2.线程同步

如果多个线程共同对某个数据修改,则可能出现不可预料的结果,为了保证数据的正确性,需要对多个线程进行同步。使用Thread对象的Lock和RLock可以实现简单的线程同步,这两个对象都有acquire方法和release方法,对于那些每次只允许一个线程操作的数据,可以将其操作放到acquire和release方法之间。

对于Lock对象而言,如果一个线程连续两次进行acquire操作,那么由于第一次acquire之后没有release,第二次acquire将挂起线程。这会导致Lock对象永远不会release,使得线程死锁。RLock对象允许一个线程多次对其进行acquire操作,因为在其内部通过一个counter变量维护着线程acquire的次数。而且每一次的acquire操作必须有一个release操作与之对应,在所有的release操作完成之后,别的线程才能申请该RLock对象。下面通过一个简单的例子演示线程同步的过程:

import threading
mylock = threading.RLock()
num=0
class myThread(threading.Thread):

    def __init__(self, name):
        threading.Thread.__init__(self, name=name)
    def run(self):
        global num
        while True:
              mylock.acquire()
              print '%s locked, Number: %d'%(threading.current_thread().name, num)
              if num>=4:
                  mylock.release()
                  print '%s released, Number: %d'%(threading.current_thread().name, num)
                  break
              num+=1
              print '%s released, Number: %d'%(threading.current_thread().name, num)
              mylock.release()

if __name__== '__main__':
    thread1 = myThread('Thread_1')
    thread2 = myThread('Thread_2')
    thread1.start()
    thread2.start()

运行结果如下:

Thread_1 locked, Number: 0
Thread_1 released, Number: 1
Thread_1 locked, Number: 1
Thread_1 released, Number: 2
Thread_2 locked, Number: 2
Thread_2 released, Number: 3
Thread_1 locked, Number: 3
Thread_1 released, Number: 4
Thread_2 locked, Number: 4
Thread_2 released, Number: 4
Thread_1 locked, Number: 4
Thread_1 released, Number: 4

3.全局解释器锁(GIL)

在Python的原始解释器CPython中存在着GIL(Global Interpreter Lock,全局解释器锁),因此在解释执行Python代码时,会产生互斥锁来限制线程对共享资源的访问,直到解释器遇到I/O操作或者操作次数达到一定数目时才会释放GIL。由于全局解释器锁的存在,在进行多线程操作的时候,不能调用多个CPU内核,只能利用一个内核,所以在进行CPU密集型操作的时候,不推荐使用多线程,更加倾向于多进程。那么多线程适合什么样的应用场景呢?对于IO密集型操作,多线程可以明显提高效率,例如Python爬虫的开发,绝大多数时间爬虫是在等待socket返回数据,网络IO的操作延时比CPU大得多。

1.4.3 协程

协程(coroutine),又称微线程,纤程,是一种用户级的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。在并发编程中,协程与线程类似,每个协程表示一个执行单元,有自己的本地数据,与其他协程共享全局数据和其他资源。

协程需要用户自己来编写调度逻辑,对于CPU来说,协程其实是单线程,所以CPU不用去考虑怎么调度、切换上下文,这就省去了CPU的切换开销,所以协程在一定程度上又好于多线程。那么在Python中是如何实现协程的呢?

Python通过yield提供了对协程的基本支持,但是不完全,而使用第三方gevent库是更好的选择,gevent提供了比较完善的协程支持。gevent是一个基于协程的Python网络函数库,使用greenlet在libev事件循环顶部提供了一个有高级别并发性的API。主要特性有以下几点:

□ 基于libev的快速事件循环,Linux上是epoll机制。

□ 基于greenlet的轻量级执行单元。

□ API复用了Python标准库里的内容。

□ 支持SSL的协作式sockets。

□ 可通过线程池或c-ares实现DNS查询。

□ 通过monkey patching功能使得第三方模块变成协作式。

gevent对协程的支持,本质上是greenlet在实现切换工作。greenlet工作流程如下:假如进行访问网络的IO操作时,出现阻塞,greenlet就显式切换到另一段没有被阻塞的代码段执行,直到原先的阻塞状况消失以后,再自动切换回原来的代码段继续处理。因此,greenlet是一种合理安排的串行方式。

由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO,这就是协程一般比多线程效率高的原因。由于切换是在IO操作时自动完成,所以gevent需要修改Python自带的一些标准库,将一些常见的阻塞,如socket、select等地方实现协程跳转,这一过程在启动时通过monkey patch完成。下面通过一个的例子来演示gevent的使用流程,代码如下:

from gevent import monkey; monkey.patch_all()
import gevent
import urllib2

def run_task(url):
    print 'Visit --> %s' % url
    try:
        response = urllib2.urlopen(url)
        data = response.read()
        print '%d bytes received from %s.' % (len(data), url)
    except Exception, e:
        print e
if __name__=='__main__':
    urls = ['https://github.com/', 'https://www.python.org/', 'http://www.cnblogs.com/']
    greenlets = [gevent.spawn(run_task, url) for url in urls  ]
    gevent.joinall(greenlets)

运行结果如下:

Visit --> https://github.com/
Visit --> https://www.python.org/
Visit --> http://www.cnblogs.com/
45740 bytes received from http://www.cnblogs.com/.
25482 bytes received from https://github.com/.
47445 bytes received from https://www.python.org/.

以上程序主要用了gevent中的spawn方法和joinall方法。spawn方法可以看做是用来形成协程,joinall方法就是添加这些协程任务,并且启动运行。从运行结果来看,3个网络操作是并发执行的,而且结束顺序不同,但其实只有一个线程。

gevent中还提供了对池的支持。当拥有动态数量的greenlet需要进行并发管理(限制并发数)时,就可以使用池,这在处理大量的网络和IO操作时是非常需要的。接下来使用gevent中pool对象,对上面的例子进行改写,程序如下:

from gevent import monkey
monkey.patch_all()
import urllib2
from gevent.pool import Pool
def run_task(url):
    print 'Visit --> %s' % url
    try:
        response = urllib2.urlopen(url)
        data = response.read()
        print '%d bytes received from %s.' % (len(data), url)
    except Exception, e:
        print e
    return 'url:%s --->finish'% url

if __name__=='__main__':
    pool = Pool(2)
    urls = ['https://github.com/', 'https://www.python.org/', 'http://www.cnblogs.com/']
    results = pool.map(run_task, urls)
    print results

运行结果如下:

Visit --> https://github.com/
Visit --> https://www.python.org/
25482 bytes received from https://github.com/.
Visit --> http://www.cnblogs.com/
47445 bytes received from https://www.python.org/.
45687 bytes received from http://www.cnblogs.com/.
['url:https://github.com/ --->finish', 'url:https://www.python.org/ --->finish',
    'url:http://www.cnblogs.com/    --->finish']

通过运行结果可以看出,Pool对象确实对协程的并发数量进行了管理,先访问了前两个网址,当其中一个任务完成时,才会执行第三个。

1.4.4 分布式进程

分布式进程指的是将Process进程分布到多台机器上,充分利用多台机器的性能完成复杂的任务。我们可以将这一点应用到分布式爬虫的开发中。

分布式进程在Python中依然要用到multiprocessing模块。multiprocessing模块不但支持多进程,其中managers子模块还支持把多进程分布到多台机器上。可以写一个服务进程作为调度者,将任务分布到其他多个进程中,依靠网络通信进行管理。举个例子:在做爬虫程序时,常常会遇到这样的场景,我们想抓取某个网站的所有图片,如果使用多进程的话,一般是一个进程负责抓取图片的链接地址,将链接地址存放到Queue中,另外的进程负责从Queue中读取链接地址进行下载和存储到本地。现在把这个过程做成分布式,一台机器上的进程负责抓取链接,其他机器上的进程负责下载存储。那么遇到的主要问题是将Queue暴露到网络中,让其他机器进程都可以访问,分布式进程就是将这一个过程进行了封装,我们可以将这个过程称为本地队列的网络化。整体过程如图1-24所示。

图1-24 分布式进程

要实现上面例子的功能,创建分布式进程需要分为六个步骤:

1)建立队列Queue,用来进行进程间的通信。服务进程创建任务队列task_queue,用来作为传递任务给任务进程的通道;服务进程创建结果队列result_queue,作为任务进程完成任务后回复服务进程的通道。在分布式多进程环境下,必须通过由Queuemanager获得的Queue接口来添加任务。

2)把第一步中建立的队列在网络上注册,暴露给其他进程(主机),注册后获得网络队列,相当于本地队列的映像。

3)建立一个对象(Queuemanager(BaseManager))实例manager,绑定端口和验证口令。

4)启动第三步中建立的实例,即启动管理manager,监管信息通道。

5)通过管理实例的方法获得通过网络访问的Queue对象,即再把网络队列实体化成可以使用的本地队列。

6)创建任务到“本地”队列中,自动上传任务到网络队列中,分配给任务进程进行处理。

接下来通过程序实现上面的例子(Linux版),首先编写的是服务进程(taskManager.py),代码如下:

import random, time, Queue
from multiprocessing.managers import BaseManager
# 第一步:建立task_queue和result_queue,用来存放任务和结果
task_queue=Queue.Queue()
result_queue=Queue.Queue()

class Queuemanager(BaseManager):
    pass
# 第二步:把创建的两个队列注册在网络上,利用register方法,callable参数关联了Queue对象,
# 将Queue对象在网络中暴露
Queuemanager.register('get_task_queue', callable=lambda:task_queue)
Queuemanager.register('get_result_queue', callable=lambda:result_queue)

# 第三步:绑定端口8001,设置验证口令‘qiye'。这个相当于对象的初始化
manager=Queuemanager(address=('',8001), authkey='qiye')

# 第四步:启动管理,监听信息通道
manager.start()

# 第五步:通过管理实例的方法获得通过网络访问的Queue对象
task=manager.get_task_queue()
result=manager.get_result_queue()

# 第六步:添加任务
for url in ["ImageUrl_"+i for i in range(10)]:
    print 'put task %s ...' %url
    task.put(url)
# 获取返回结果
print 'try get result...'
for i in range(10):
    print 'result is %s' %result.get(timeout=10)
# 关闭管理
manager.shutdown()

任务进程已经编写完成,接下来编写任务进程(taskWorker.py),创建任务进程的步骤相对较少,需要四个步骤:

1)使用QueueManager注册用于获取Queue的方法名称,任务进程只能通过名称来在网络上获取Queue。

2)连接服务器,端口和验证口令注意保持与服务进程中完全一致。

3)从网络上获取Queue,进行本地化。

4)从task队列获取任务,并把结果写入result队列。

程序taskWorker.py代码(win/linux版)如下:

# coding:utf-8
import time
from multiprocessing.managers import BaseManager
# 创建类似的QueueManager:
class QueueManager(BaseManager):
    pass
# 第一步:使用QueueManager注册用于获取Queue的方法名称
QueueManager.register('get_task_queue')
QueueManager.register('get_result_queue')
# 第二步:连接到服务器:
server_addr = '127.0.0.1'
print('Connect to server %s...' % server_addr)
# 端口和验证口令注意保持与服务进程完全一致:
m = QueueManager(address=(server_addr, 8001), authkey='qiye')
# 从网络连接:
m.connect()
# 第三步:获取Queue的对象:
task = m.get_task_queue()
result = m.get_result_queue()
# 第四步:从task队列获取任务,并把结果写入result队列:
while(not task.empty()):
        image_url = task.get(True, timeout=5)
        print('run task download %s...' % image_url)
        time.sleep(1)
        result.put('%s--->success'%image_url)

# 处理结束:
print('worker exit.')

最后开始运行程序,先启动服务进程taskManager.py,运行结果如下:

put task ImageUrl_0 ...
put task ImageUrl_1 ...
put task ImageUrl_2 ...
put task ImageUrl_3 ...
put task ImageUrl_4 ...
put task ImageUrl_5 ...
put task ImageUrl_6 ...
put task ImageUrl_7 ...
put task ImageUrl_8 ...
put task ImageUrl_9 ...
try get result...

接着再启动任务进程taskWorker.py,运行结果如下:

Connect to server 127.0.0.1...
run task download ImageUrl_0...
run task download ImageUrl_1...
run task download ImageUrl_2...
run task download ImageUrl_3...
run task download ImageUrl_4...
run task download ImageUrl_5...
run task download ImageUrl_6...
run task download ImageUrl_7...
run task download ImageUrl_8...
run task download ImageUrl_9...
worker exit.

当任务进程运行结束后,服务进程运行结果如下:

result is ImageUrl_0--->success
result is ImageUrl_1--->success
result is ImageUrl_2--->success
result is ImageUrl_3--->success
result is ImageUrl_4--->success
result is ImageUrl_5--->success
result is ImageUrl_6--->success
result is ImageUrl_7--->success
result is ImageUrl_8--->success
result is ImageUrl_9--->success

其实这就是一个简单但真正的分布式计算,把代码稍加改造,启动多个worker,就可以把任务分布到几台甚至几十台机器上,实现大规模的分布式爬虫。

注意

由于平台的特性,创建服务进程的代码在Linux和Windows上有一些不同,创建工作进程的代码是一致的。

taskManager.py程序在Windows版下的代码如下:

# coding:utf-8
# taskManager.py for windows
import Queue
from multiprocessing.managers import BaseManager
from multiprocessing import freeze_support
# 任务个数
task_number = 10
# 定义收发队列
task_queue = Queue.Queue(task_number);
result_queue = Queue.Queue(task_number);
def get_task():
    return task_queue
def get_result():
      return result_queue
# 创建类似的QueueManager:
class QueueManager(BaseManager):
    pass
def win_run():
    # Windows下绑定调用接口不能使用lambda,所以只能先定义函数再绑定
    QueueManager.register('get_task_queue', callable = get_task)
    QueueManager.register('get_result_queue', callable = get_result)
    # 绑定端口并设置验证口令,Windows下需要填写IP地址,Linux下不填默认为本地
    manager = QueueManager(address = ('127.0.0.1',8001), authkey = 'qiye')
    # 启动
    manager.start()
    try:
        # 通过网络获取任务队列和结果队列
        task = manager.get_task_queue()
        result = manager.get_result_queue()
        # 添加任务
        for url in ["ImageUrl_"+str(i) for i in range(10)]:
              print 'put task %s ...' %url
              task.put(url)
        print 'try get result...'
        for i in range(10):
              print 'result is %s' %result.get(timeout=10)
    except:
        print('Manager error')
    finally:
        # 一定要关闭,否则会报管道未关闭的错误
        manager.shutdown()

if __name__ == '__main__':
    # Windows下多进程可能会有问题,添加这句可以缓解
    freeze_support()
    win_run()

1.5 网络编程

既然是做爬虫开发,必然需要了解Python网络编程方面的知识。计算机网络是把各个计算机连接到一起,让网络中的计算机可以互相通信。网络编程就是如何在程序中实现两台计算机的通信。例如当你使用浏览器访问谷歌网站时,你的计算机就和谷歌的某台服务器通过互联网建立起了连接,然后谷歌服务器会把把网页内容作为数据通过互联网传输到你的电脑上。

网络编程对所有开发语言都是一样的,Python也不例外。使用Python进行网络编程时,实际上是在Python程序本身这个进程内,连接到指定服务器进程的通信端口进行通信,所以网络通信也可以看做两个进程间的通信。

提到网络编程,必须提到的一个概念是Socket。Socket(套接字)是网络编程的一个抽象概念,通常我们用一个Socket表示“打开了一个网络链接”,而打开一个Socket需要知道目标计算机的IP地址和端口号,再指定协议类型即可。Python提供了两个基本的Socket模块:

□ Socket,提供了标准的BSD Sockets API。

□ SocketServer,提供了服务器中心类,可以简化网络服务器的开发。

下面讲一下Socket模块功能。

1.Socket类型

套接字格式为:socket(family, type[, protocal]),使用给定的地址族、套接字类型(如表1-2所示)、协议编号(默认为0)来创建套接字。

表1-2 Socket类型及说明

2.Socket函数

表1-3列举了Python网络编程常用的函数,其中包括了TCP和UDP。

表1-3 Socket函数及说明

本节接下来主要介绍Python中TCP和UDP两种网络类型的编程流程。

1.5.1 TCP编程

网络编程一般包括两部分:服务端和客户端。TCP是一种面向连接的通信方式,主动发起连接的叫客户端,被动响应连接的叫服务端。首先说一下服务端,创建和运行TCP服务端一般需要五个步骤:

1)创建Socket,绑定Socket到本地IP与端口。

2)开始监听连接。

3)进入循环,不断接收客户端的连接请求。

4)接收传来的数据,并发送给对方数据。

5)传输完毕后,关闭Socket。

下面通过一个例子演示创建TCP服务端的过程,程序如下:

# coding:utf-8
import socket
import threading
import time
def dealClient(sock, addr):
    # 第四步:接收传来的数据,并发送给对方数据
    print('Accept new connection from %s:%s...' % addr)
    sock.send(b'Hello, I am server! ')
    while True:
        data = sock.recv(1024)
        time.sleep(1)
        if not data or data.decode('utf-8') == 'exit':
              break
        print '-->>%s! ' % data.decode('utf-8')
        sock.send(('Loop_Msg: %s! ' % data.decode('utf-8')).encode('utf-8'))
    # 第五步:关闭Socket
    sock.close()
    print('Connection from %s:%s closed.' % addr)
if __name__=="__main__":
    # 第一步:创建一个基于IPv4和TCP协议的Socket
    # Socket绑定的IP(127.0.0.1为本机IP)与端口
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(('127.0.0.1', 9999))
    # 第二步:监听连接
    s.listen(5)
    print('Waiting for connection...')
    while True:
        # 第三步:接收一个新连接:
        sock, addr = s.accept()
        # 创建新线程来处理TCP连接:
        t = threading.Thread(target=dealClient, args=(sock, addr))
        t.start()

接着编写客户端,与服务端进行交互,TCP客户端的创建和运行需要三个步骤:

1)创建Socket,连接远端地址。

2)连接后发送数据和接收数据。

3)传输完毕后,关闭Socket。

程序如下:

# coding:utf-8
import socket
# 初始化Socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 连接目标的IP和端口
s.connect(('127.0.0.1', 9999))
# 接收消息
print('-->>'+s.recv(1024).decode('utf-8'))
# 发送消息
s.send(b'Hello, I am a client')
print('-->>'+s.recv(1024).decode('utf-8'))
s.send(b'exit')
# 关闭Socket
s.close()

最后看一下运行结果,先启动服务端,再启动客户端。服务端打印的信息如下:

Waiting for connection...
Accept new connection from 127.0.0.1:20164...
-->>Hello, I am a client!
Connection from 127.0.0.1:20164 closed.

客户端输出信息如下:

-->>Hello, I am server!
-->>Loop_Msg: Hello, I am a client!

以上完成了TCP客户端与服务端的交互流程,用TCP协议进行Socket编程在Python中十分简单。对于客户端,要主动连接服务器的IP和指定端口;对于服务器,要首先监听指定端口,然后,对每一个新的连接,创建一个线程或进程来处理。通常,服务器程序会无限运行下去。

1.5.2 UDP编程

TCP通信需要一个建立可靠连接的过程,而且通信双方以流的形式发送数据。相对于TCP, UDP则是面向无连接的协议。使用UDP协议时,不需要建立连接,只需要知道对方的IP地址和端口号,就可以直接发数据包,但是不关心是否能到达目的端。虽然用UDP传输数据不可靠,但是由于它没有建立连接的过程,速度比TCP快得多,对于不要求可靠到达的数据,就可以使用UDP协议。

使用UDP协议,和TCP一样,也有服务端和客户端之分。UDP编程相对于TCP编程比较简单,服务端创建和运行只需要三个步骤:

1)创建Socket,绑定指定的IP和端口。

2)直接发送数据和接收数据。

3)关闭Socket。

示例程序如下:

# coding:utf-8
import socket
# 创建Socket,绑定指定的IP和端口
# SOCK_DGRAM指定了这个Socket的类型是UDP,绑定端口和TCP示例一样。
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('127.0.0.1', 9999))
print('Bind UDP on 9999...')
while True:
    # 直接发送数据和接收数据
    data, addr = s.recvfrom(1024)
    print('Received from %s:%s.' % addr)
    s.sendto(b'Hello, %s! ' % data, addr)

客户端的创建和运行更加简单,创建Socket,直接可以与服务端进行数据交换,示例如下:

# coding:utf-8
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for data in [b'Hello', b'World']:
    # 发送数据:
    s.sendto(data, ('127.0.0.1', 9999))
    # 接收数据:
    print(s.recv(1024).decode('utf-8'))
s.close()

以上就是UDP服务端和客户端数据交互的流程,UDP的使用与TCP类似,但是不需要建立连接。此外,服务器绑定UDP端口和TCP端口互不冲突,即UDP的9999端口与TCP的9999端口可以各自绑定。

1.6 小结

本章主要讲解了Python的编程基础,包括IO编程、进程和线程、网络编程等三个方面。这三个方面在Python爬虫开发中经常用到,熟悉这些知识点,对于之后的开发将起到事半功倍的效果。如果对于Python编程基础不是很熟练,希望能将本章讲的三个知识点着重复习,将书中的例子灵活运用并加以改进。